utcp-cli 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- utcp_cli-1.0.0/PKG-INFO +25 -0
- utcp_cli-1.0.0/pyproject.toml +43 -0
- utcp_cli-1.0.0/setup.cfg +4 -0
- utcp_cli-1.0.0/src/utcp_cli/__init__.py +13 -0
- utcp_cli-1.0.0/src/utcp_cli/cli_call_template.py +44 -0
- utcp_cli-1.0.0/src/utcp_cli/cli_communication_protocol.py +482 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/PKG-INFO +25 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/SOURCES.txt +11 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/dependency_links.txt +1 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/entry_points.txt +2 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/requires.txt +11 -0
- utcp_cli-1.0.0/src/utcp_cli.egg-info/top_level.txt +1 -0
- utcp_cli-1.0.0/tests/test_cli_communication_protocol.py +591 -0
utcp_cli-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: utcp>=1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: coverage; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "utcp-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "UTCP Contributors" },
|
|
10
|
+
]
|
|
11
|
+
description = "Universal Tool Calling Protocol (UTCP) client library for Python"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
"pyyaml>=6.0",
|
|
17
|
+
"utcp>=1.0"
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
license = "MPL-2.0"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"build",
|
|
30
|
+
"pytest",
|
|
31
|
+
"pytest-asyncio",
|
|
32
|
+
"pytest-cov",
|
|
33
|
+
"coverage",
|
|
34
|
+
"twine",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://utcp.io"
|
|
39
|
+
Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
|
|
40
|
+
Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
|
|
41
|
+
|
|
42
|
+
[project.entry-points."utcp.plugins"]
|
|
43
|
+
cli = "utcp_cli:register"
|
utcp_cli-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
|
|
2
|
+
from utcp_cli.cli_communication_protocol import CliCommunicationProtocol
|
|
3
|
+
from utcp_cli.cli_call_template import CliCallTemplate, CliCallTemplateSerializer
|
|
4
|
+
|
|
5
|
+
def register():
|
|
6
|
+
register_communication_protocol("cli", CliCommunicationProtocol())
|
|
7
|
+
register_call_template("cli", CliCallTemplateSerializer())
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CliCommunicationProtocol",
|
|
11
|
+
"CliCallTemplate",
|
|
12
|
+
"CliCallTemplateSerializer",
|
|
13
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional, Dict, Literal
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
|
|
4
|
+
from utcp.data.call_template import CallTemplate
|
|
5
|
+
from utcp.interfaces.serializer import Serializer
|
|
6
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
class CliCallTemplate(CallTemplate):
|
|
10
|
+
"""Call template configuration for Command Line Interface tools.
|
|
11
|
+
|
|
12
|
+
Enables execution of command-line tools and programs as UTCP providers.
|
|
13
|
+
Supports environment variable injection and custom working directories.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
call_template_type: Always "cli" for CLI providers.
|
|
17
|
+
command_name: The name or path of the command to execute.
|
|
18
|
+
env_vars: Optional environment variables to set during command execution.
|
|
19
|
+
working_dir: Optional custom working directory for command execution.
|
|
20
|
+
auth: Always None - CLI providers don't support authentication.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
call_template_type: Literal["cli"] = "cli"
|
|
24
|
+
command_name: str
|
|
25
|
+
env_vars: Optional[Dict[str, str]] = Field(
|
|
26
|
+
default=None, description="Environment variables to set when executing the command"
|
|
27
|
+
)
|
|
28
|
+
working_dir: Optional[str] = Field(
|
|
29
|
+
default=None, description="Working directory for command execution"
|
|
30
|
+
)
|
|
31
|
+
auth: None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CliCallTemplateSerializer(Serializer[CliCallTemplate]):
|
|
35
|
+
"""Serializer for CliCallTemplate."""
|
|
36
|
+
|
|
37
|
+
def to_dict(self, obj: CliCallTemplate) -> dict:
|
|
38
|
+
return obj.model_dump()
|
|
39
|
+
|
|
40
|
+
def validate_dict(self, obj: dict) -> CliCallTemplate:
|
|
41
|
+
try:
|
|
42
|
+
return CliCallTemplate.model_validate(obj)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise UtcpSerializerValidationError("Invalid CliCallTemplate: " + traceback.format_exc()) from e
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) transport for UTCP client.
|
|
2
|
+
|
|
3
|
+
This module provides the CLI transport implementation that enables UTCP clients
|
|
4
|
+
to interact with command-line tools and processes. It handles tool discovery
|
|
5
|
+
through startup commands, tool execution with proper argument formatting,
|
|
6
|
+
and output processing with JSON parsing capabilities.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Asynchronous command execution with timeout handling
|
|
10
|
+
- Tool discovery via startup commands that output UTCP manuals
|
|
11
|
+
- Flexible argument formatting for command-line flags
|
|
12
|
+
- Environment variable support for authentication and configuration
|
|
13
|
+
- JSON output parsing with fallback to raw text
|
|
14
|
+
- Cross-platform command parsing (Windows/Unix)
|
|
15
|
+
- Working directory control for command execution
|
|
16
|
+
|
|
17
|
+
Security:
|
|
18
|
+
- Command execution is isolated through subprocess
|
|
19
|
+
- Environment variables can be controlled per provider
|
|
20
|
+
- Working directory can be restricted
|
|
21
|
+
"""
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
from typing import Dict, Any, List, Optional, Callable, AsyncGenerator
|
|
27
|
+
|
|
28
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
29
|
+
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
30
|
+
from utcp.data.tool import Tool
|
|
31
|
+
from utcp.data.utcp_manual import UtcpManual, UtcpManualSerializer
|
|
32
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
33
|
+
from utcp_cli.cli_call_template import CliCallTemplate, CliCallTemplateSerializer
|
|
34
|
+
import logging
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CliCommunicationProtocol(CommunicationProtocol):
|
|
40
|
+
"""Transport implementation for CLI-based tool providers.
|
|
41
|
+
|
|
42
|
+
Handles communication with command-line tools by executing processes
|
|
43
|
+
and managing their input/output. Supports both tool discovery and
|
|
44
|
+
execution phases with comprehensive error handling and timeout management.
|
|
45
|
+
|
|
46
|
+
Features:
|
|
47
|
+
- Asynchronous subprocess execution with proper cleanup
|
|
48
|
+
- Tool discovery through startup commands returning UTCP manuals
|
|
49
|
+
- Flexible argument formatting for various CLI conventions
|
|
50
|
+
- Environment variable injection for authentication
|
|
51
|
+
- JSON output parsing with graceful fallback to text
|
|
52
|
+
- Cross-platform command parsing and execution
|
|
53
|
+
- Configurable working directories and timeouts
|
|
54
|
+
- Process lifecycle management with proper termination
|
|
55
|
+
|
|
56
|
+
Architecture:
|
|
57
|
+
CLI tools are discovered by executing the provider's command_name
|
|
58
|
+
and parsing the output for UTCP manual JSON. Tool calls execute
|
|
59
|
+
the same command with formatted arguments and return processed output.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
_log: Logger function for debugging and error reporting.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
"""Initialize the CLI transport."""
|
|
67
|
+
|
|
68
|
+
def _log_info(self, message: str):
|
|
69
|
+
"""Log informational messages."""
|
|
70
|
+
logger.info(f"[CliCommunicationProtocol] {message}")
|
|
71
|
+
|
|
72
|
+
def _log_error(self, message: str):
|
|
73
|
+
"""Log error messages."""
|
|
74
|
+
logger.error(f"[CliCommunicationProtocol Error] {message}")
|
|
75
|
+
|
|
76
|
+
def _prepare_environment(self, provider: CliCallTemplate) -> Dict[str, str]:
|
|
77
|
+
"""Prepare environment variables for command execution.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
provider: The CLI provider
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Environment variables dictionary
|
|
84
|
+
"""
|
|
85
|
+
import os
|
|
86
|
+
env = os.environ.copy()
|
|
87
|
+
|
|
88
|
+
# Add custom environment variables if provided
|
|
89
|
+
if provider.env_vars:
|
|
90
|
+
env.update(provider.env_vars)
|
|
91
|
+
|
|
92
|
+
return env
|
|
93
|
+
|
|
94
|
+
async def _execute_command(
|
|
95
|
+
self,
|
|
96
|
+
command: List[str],
|
|
97
|
+
env: Dict[str, str],
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
input_data: Optional[str] = None,
|
|
100
|
+
working_dir: Optional[str] = None
|
|
101
|
+
) -> tuple[str, str, int]:
|
|
102
|
+
"""Execute a command asynchronously.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
command: Command and arguments to execute
|
|
106
|
+
env: Environment variables
|
|
107
|
+
timeout: Timeout in seconds
|
|
108
|
+
input_data: Optional input data to pass to the command
|
|
109
|
+
working_dir: Working directory for command execution
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (stdout, stderr, return_code)
|
|
113
|
+
"""
|
|
114
|
+
process = None
|
|
115
|
+
try:
|
|
116
|
+
process = await asyncio.create_subprocess_exec(
|
|
117
|
+
*command,
|
|
118
|
+
stdout=asyncio.subprocess.PIPE,
|
|
119
|
+
stderr=asyncio.subprocess.PIPE,
|
|
120
|
+
env=env,
|
|
121
|
+
cwd=working_dir,
|
|
122
|
+
stdin=asyncio.subprocess.PIPE if input_data else None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
126
|
+
process.communicate(input=input_data.encode('utf-8') if input_data else None),
|
|
127
|
+
timeout=timeout
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
stdout = stdout_bytes.decode('utf-8', errors='replace')
|
|
131
|
+
stderr = stderr_bytes.decode('utf-8', errors='replace')
|
|
132
|
+
|
|
133
|
+
return stdout, stderr, process.returncode or 0
|
|
134
|
+
|
|
135
|
+
except asyncio.TimeoutError:
|
|
136
|
+
# Kill the process if it times out
|
|
137
|
+
if process:
|
|
138
|
+
try:
|
|
139
|
+
process.kill()
|
|
140
|
+
await process.wait()
|
|
141
|
+
except ProcessLookupError:
|
|
142
|
+
pass # Process already terminated
|
|
143
|
+
self._log_error(f"Command timed out after {timeout} seconds: {' '.join(command)}")
|
|
144
|
+
raise
|
|
145
|
+
except Exception as e:
|
|
146
|
+
# Ensure process is cleaned up on any error
|
|
147
|
+
if process:
|
|
148
|
+
try:
|
|
149
|
+
process.kill()
|
|
150
|
+
await process.wait()
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
pass # Process already terminated
|
|
153
|
+
self._log_error(f"Error executing command {' '.join(command)}: {e}")
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
157
|
+
"""Register a CLI manual and discover its tools.
|
|
158
|
+
|
|
159
|
+
Executes the call template's command_name and looks for a UTCP manual JSON in the output.
|
|
160
|
+
"""
|
|
161
|
+
if not isinstance(manual_call_template, CliCallTemplate):
|
|
162
|
+
raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate")
|
|
163
|
+
|
|
164
|
+
if not manual_call_template.command_name:
|
|
165
|
+
raise ValueError(f"CliCallTemplate '{manual_call_template.name}' must have command_name set")
|
|
166
|
+
|
|
167
|
+
self._log_info(
|
|
168
|
+
f"Registering CLI manual '{manual_call_template.name}' with command '{manual_call_template.command_name}'"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
env = self._prepare_environment(manual_call_template)
|
|
173
|
+
# Parse command string into proper arguments
|
|
174
|
+
# Use posix=False on Windows, posix=True on Unix-like systems
|
|
175
|
+
command = shlex.split(manual_call_template.command_name, posix=(os.name != 'nt'))
|
|
176
|
+
|
|
177
|
+
self._log_info(f"Executing command for tool discovery: {' '.join(command)}")
|
|
178
|
+
|
|
179
|
+
stdout, stderr, return_code = await self._execute_command(
|
|
180
|
+
command,
|
|
181
|
+
env,
|
|
182
|
+
timeout=30.0,
|
|
183
|
+
working_dir=manual_call_template.working_dir,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Get output based on exit code
|
|
187
|
+
output = stdout if return_code == 0 else stderr
|
|
188
|
+
|
|
189
|
+
if not output.strip():
|
|
190
|
+
self._log_info(
|
|
191
|
+
f"No output from command '{manual_call_template.command_name}'"
|
|
192
|
+
)
|
|
193
|
+
return RegisterManualResult(
|
|
194
|
+
success=False,
|
|
195
|
+
manual_call_template=manual_call_template,
|
|
196
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
197
|
+
errors=[
|
|
198
|
+
f"No output from discovery command for CLI provider '{manual_call_template.name}'"
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Try to parse UTCPManual from the output
|
|
203
|
+
utcp_manual = self._extract_utcp_manual_from_output(
|
|
204
|
+
output, manual_call_template.name
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if utcp_manual is None:
|
|
208
|
+
error_msg = (
|
|
209
|
+
f"Could not parse UTCP manual from CLI provider '{manual_call_template.name}' output"
|
|
210
|
+
)
|
|
211
|
+
self._log_error(error_msg)
|
|
212
|
+
return RegisterManualResult(
|
|
213
|
+
success=False,
|
|
214
|
+
manual_call_template=manual_call_template,
|
|
215
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
216
|
+
errors=[error_msg],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._log_info(
|
|
220
|
+
f"Discovered {len(utcp_manual.tools)} tools from CLI provider '{manual_call_template.name}'"
|
|
221
|
+
)
|
|
222
|
+
return RegisterManualResult(
|
|
223
|
+
success=True,
|
|
224
|
+
manual_call_template=manual_call_template,
|
|
225
|
+
manual=utcp_manual,
|
|
226
|
+
errors=[],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
error_msg = f"Error discovering tools from CLI provider '{manual_call_template.name}': {e}"
|
|
231
|
+
self._log_error(error_msg)
|
|
232
|
+
return RegisterManualResult(
|
|
233
|
+
success=False,
|
|
234
|
+
manual_call_template=manual_call_template,
|
|
235
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
236
|
+
errors=[error_msg],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
|
|
240
|
+
"""Deregister a CLI manual (no-op)."""
|
|
241
|
+
if isinstance(manual_call_template, CliCallTemplate):
|
|
242
|
+
self._log_info(
|
|
243
|
+
f"Deregistering CLI manual '{manual_call_template.name}' (no-op)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _format_arguments(self, tool_args: Dict[str, Any]) -> List[str]:
|
|
247
|
+
"""Format arguments for command-line execution.
|
|
248
|
+
|
|
249
|
+
Converts a dictionary of arguments into command-line flags and values.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
tool_args: Dictionary of argument names and values
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
List of command-line arguments
|
|
256
|
+
"""
|
|
257
|
+
args = []
|
|
258
|
+
for key, value in tool_args.items():
|
|
259
|
+
if isinstance(value, bool):
|
|
260
|
+
if value:
|
|
261
|
+
args.append(f"--{key}")
|
|
262
|
+
elif isinstance(value, (list, tuple)):
|
|
263
|
+
for item in value:
|
|
264
|
+
args.extend([f"--{key}", str(item)])
|
|
265
|
+
else:
|
|
266
|
+
args.extend([f"--{key}", str(value)])
|
|
267
|
+
return args
|
|
268
|
+
|
|
269
|
+
def _extract_utcp_manual_from_output(self, output: str, provider_name: str) -> Optional[UtcpManual]:
|
|
270
|
+
"""Extract a UTCP manual from command output.
|
|
271
|
+
|
|
272
|
+
Tries to parse the output as a UTCP manual. If it instead looks like a list of tools,
|
|
273
|
+
wraps them in a basic UtcpManual structure.
|
|
274
|
+
"""
|
|
275
|
+
# Try to parse the entire output as JSON first
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(output.strip())
|
|
278
|
+
if isinstance(data, dict) and "utcp_version" in data and "tools" in data:
|
|
279
|
+
try:
|
|
280
|
+
return UtcpManualSerializer().validate_dict(data)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
self._log_error(
|
|
283
|
+
f"Invalid UTCP manual format from provider '{provider_name}': {e}"
|
|
284
|
+
)
|
|
285
|
+
# Fallback: try to parse tools from possibly-legacy structure
|
|
286
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
287
|
+
if tools:
|
|
288
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
289
|
+
return None
|
|
290
|
+
# Fallback: try to parse as tools
|
|
291
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
292
|
+
if tools:
|
|
293
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
# Look for JSON objects within the output text and aggregate tools
|
|
298
|
+
aggregated_tools: List[Tool] = []
|
|
299
|
+
lines = output.split('\n')
|
|
300
|
+
for line in lines:
|
|
301
|
+
line = line.strip()
|
|
302
|
+
if line.startswith('{') and line.endswith('}'):
|
|
303
|
+
try:
|
|
304
|
+
data = json.loads(line)
|
|
305
|
+
# If a full manual is found in a line, return it immediately
|
|
306
|
+
if isinstance(data, dict) and "utcp_version" in data and "tools" in data:
|
|
307
|
+
try:
|
|
308
|
+
return UtcpManualSerializer().validate_dict(data)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
self._log_error(
|
|
311
|
+
f"Invalid UTCP manual format from provider '{provider_name}': {e}"
|
|
312
|
+
)
|
|
313
|
+
# Fallback: try to parse tools from possibly-legacy structure
|
|
314
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
315
|
+
if tools:
|
|
316
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
317
|
+
return None
|
|
318
|
+
found_tools = self._parse_tool_data(data, provider_name)
|
|
319
|
+
aggregated_tools.extend(found_tools)
|
|
320
|
+
except json.JSONDecodeError:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
if aggregated_tools:
|
|
324
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=aggregated_tools)
|
|
325
|
+
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def _build_tool_from_dict(self, tool_data: Any, provider_name: str) -> Optional[Tool]:
|
|
329
|
+
"""Build a Tool object from a dictionary, supporting legacy keys.
|
|
330
|
+
|
|
331
|
+
This maps legacy 'tool_provider' into the new 'tool_call_template'
|
|
332
|
+
using the appropriate call template serializers.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
if isinstance(tool_data, dict):
|
|
336
|
+
# If already new-style and call template is a dict, validate it
|
|
337
|
+
if "tool_call_template" in tool_data and isinstance(tool_data["tool_call_template"], dict):
|
|
338
|
+
td = dict(tool_data)
|
|
339
|
+
td["tool_call_template"] = CallTemplateSerializer().validate_dict(td["tool_call_template"])
|
|
340
|
+
return Tool(**td)
|
|
341
|
+
|
|
342
|
+
# Legacy style: 'tool_provider'
|
|
343
|
+
if "tool_provider" in tool_data and isinstance(tool_data["tool_provider"], dict):
|
|
344
|
+
provider = tool_data["tool_provider"]
|
|
345
|
+
provider_type = provider.get("provider_type") or provider.get("type")
|
|
346
|
+
# Normalize to call template dict
|
|
347
|
+
call_template_dict = {k: v for k, v in provider.items() if k != "provider_type"}
|
|
348
|
+
call_template_dict["type"] = provider_type
|
|
349
|
+
|
|
350
|
+
# Validate based on type
|
|
351
|
+
if provider_type == "cli":
|
|
352
|
+
call_template = CliCallTemplateSerializer().validate_dict(call_template_dict)
|
|
353
|
+
else:
|
|
354
|
+
call_template = CallTemplateSerializer().validate_dict(call_template_dict)
|
|
355
|
+
|
|
356
|
+
td = dict(tool_data)
|
|
357
|
+
td.pop("tool_provider", None)
|
|
358
|
+
td["tool_call_template"] = call_template
|
|
359
|
+
return Tool(**td)
|
|
360
|
+
|
|
361
|
+
# Already a Tool-like dict with correct fields
|
|
362
|
+
return Tool(**tool_data)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
self._log_error(f"Invalid tool definition from provider '{provider_name}': {e}")
|
|
365
|
+
return None
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def _parse_tool_data(self, data: Any, provider_name: str) -> List[Tool]:
|
|
369
|
+
"""Parse tool data from JSON.
|
|
370
|
+
|
|
371
|
+
Supports both the new format (with 'tool_call_template') and the
|
|
372
|
+
legacy format (with 'tool_provider').
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
data: JSON data to parse
|
|
376
|
+
provider_name: Name of the provider for logging
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
List of tools parsed from the data
|
|
380
|
+
"""
|
|
381
|
+
tools: List[Tool] = []
|
|
382
|
+
if isinstance(data, dict):
|
|
383
|
+
if 'tools' in data and isinstance(data['tools'], list):
|
|
384
|
+
for item in data['tools']:
|
|
385
|
+
built = self._build_tool_from_dict(item, provider_name)
|
|
386
|
+
if built is not None:
|
|
387
|
+
tools.append(built)
|
|
388
|
+
return tools
|
|
389
|
+
elif 'name' in data and 'description' in data:
|
|
390
|
+
built = self._build_tool_from_dict(data, provider_name)
|
|
391
|
+
return [built] if built is not None else []
|
|
392
|
+
elif isinstance(data, list):
|
|
393
|
+
for item in data:
|
|
394
|
+
built = self._build_tool_from_dict(item, provider_name)
|
|
395
|
+
if built is not None:
|
|
396
|
+
tools.append(built)
|
|
397
|
+
return tools
|
|
398
|
+
|
|
399
|
+
return tools
|
|
400
|
+
|
|
401
|
+
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
402
|
+
"""Call a CLI tool.
|
|
403
|
+
|
|
404
|
+
Executes the command specified by provider.command_name with the provided arguments.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
caller: The UTCP client that is calling this method.
|
|
408
|
+
tool_name: Name of the tool to call
|
|
409
|
+
tool_args: Arguments for the tool call
|
|
410
|
+
tool_call_template: The CliCallTemplate for the tool
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
The output from the command execution based on exit code:
|
|
414
|
+
- If exit code is 0: stdout (parsed as JSON if possible, otherwise raw string)
|
|
415
|
+
- If exit code is not 0: stderr
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
ValueError: If provider is not a CliProvider or command_name is not set
|
|
419
|
+
"""
|
|
420
|
+
if not isinstance(tool_call_template, CliCallTemplate):
|
|
421
|
+
raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate")
|
|
422
|
+
|
|
423
|
+
if not tool_call_template.command_name:
|
|
424
|
+
raise ValueError(f"CliCallTemplate '{tool_call_template.name}' must have command_name set")
|
|
425
|
+
|
|
426
|
+
# Build the command
|
|
427
|
+
# Parse command string into proper arguments
|
|
428
|
+
# Use posix=False on Windows, posix=True on Unix-like systems
|
|
429
|
+
command = shlex.split(tool_call_template.command_name, posix=(os.name != 'nt'))
|
|
430
|
+
|
|
431
|
+
# Add formatted arguments
|
|
432
|
+
if tool_args:
|
|
433
|
+
command.extend(self._format_arguments(tool_args))
|
|
434
|
+
|
|
435
|
+
self._log_info(f"Executing CLI tool '{tool_name}': {' '.join(command)}")
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
env = self._prepare_environment(tool_call_template)
|
|
439
|
+
|
|
440
|
+
stdout, stderr, return_code = await self._execute_command(
|
|
441
|
+
command,
|
|
442
|
+
env,
|
|
443
|
+
timeout=60.0, # Longer timeout for tool execution
|
|
444
|
+
working_dir=tool_call_template.working_dir
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Get output based on exit code
|
|
448
|
+
if return_code == 0:
|
|
449
|
+
output = stdout
|
|
450
|
+
self._log_info(f"CLI tool '{tool_name}' executed successfully (exit code 0)")
|
|
451
|
+
else:
|
|
452
|
+
output = stderr
|
|
453
|
+
self._log_info(f"CLI tool '{tool_name}' exited with code {return_code}, returning stderr")
|
|
454
|
+
|
|
455
|
+
# Try to parse output as JSON, fall back to raw string
|
|
456
|
+
if output.strip():
|
|
457
|
+
try:
|
|
458
|
+
result = json.loads(output)
|
|
459
|
+
self._log_info(f"Returning JSON output from CLI tool '{tool_name}'")
|
|
460
|
+
return result
|
|
461
|
+
except json.JSONDecodeError:
|
|
462
|
+
# Return raw string output
|
|
463
|
+
self._log_info(f"Returning text output from CLI tool '{tool_name}'")
|
|
464
|
+
return output.strip()
|
|
465
|
+
else:
|
|
466
|
+
self._log_info(f"CLI tool '{tool_name}' produced no output")
|
|
467
|
+
return ""
|
|
468
|
+
|
|
469
|
+
except Exception as e:
|
|
470
|
+
self._log_error(f"Error executing CLI tool '{tool_name}': {e}")
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
|
|
474
|
+
"""Streaming calls are not supported for CLI protocol."""
|
|
475
|
+
raise NotImplementedError("Streaming is not supported by the CLI communication protocol.")
|
|
476
|
+
|
|
477
|
+
async def close(self) -> None:
|
|
478
|
+
"""Close the transport.
|
|
479
|
+
|
|
480
|
+
This is a no-op for CLI transports since they don't maintain connections.
|
|
481
|
+
"""
|
|
482
|
+
self._log_info("Closing CLI transport (no-op)")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: utcp>=1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: coverage; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/utcp_cli/__init__.py
|
|
3
|
+
src/utcp_cli/cli_call_template.py
|
|
4
|
+
src/utcp_cli/cli_communication_protocol.py
|
|
5
|
+
src/utcp_cli.egg-info/PKG-INFO
|
|
6
|
+
src/utcp_cli.egg-info/SOURCES.txt
|
|
7
|
+
src/utcp_cli.egg-info/dependency_links.txt
|
|
8
|
+
src/utcp_cli.egg-info/entry_points.txt
|
|
9
|
+
src/utcp_cli.egg-info/requires.txt
|
|
10
|
+
src/utcp_cli.egg-info/top_level.txt
|
|
11
|
+
tests/test_cli_communication_protocol.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utcp_cli
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the CLI transport interface.
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import pytest_asyncio
|
|
14
|
+
|
|
15
|
+
from utcp_cli.cli_communication_protocol import CliCommunicationProtocol
|
|
16
|
+
from utcp_cli.cli_call_template import CliCallTemplate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest_asyncio.fixture
|
|
20
|
+
async def transport() -> CliCommunicationProtocol:
|
|
21
|
+
"""Provides a clean CliCommunicationProtocol instance."""
|
|
22
|
+
t = CliCommunicationProtocol()
|
|
23
|
+
yield t
|
|
24
|
+
# Optional cleanup if close() exists
|
|
25
|
+
if hasattr(t, "close") and asyncio.iscoroutinefunction(getattr(t, "close")):
|
|
26
|
+
await t.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest_asyncio.fixture
|
|
30
|
+
def mock_cli_script():
|
|
31
|
+
"""Create a mock CLI script that can be executed for testing."""
|
|
32
|
+
script_content = '''#!/usr/bin/env python3
|
|
33
|
+
import sys
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
|
|
37
|
+
def main():
|
|
38
|
+
# Check for tool discovery mode (no arguments)
|
|
39
|
+
if len(sys.argv) == 1:
|
|
40
|
+
# Return UTCP manual
|
|
41
|
+
tools_data = {
|
|
42
|
+
"version": "1.0.0",
|
|
43
|
+
"name": "Mock CLI Tools",
|
|
44
|
+
"description": "Mock CLI tools for testing",
|
|
45
|
+
"tools": [
|
|
46
|
+
{
|
|
47
|
+
"name": "echo",
|
|
48
|
+
"description": "Echo back the input",
|
|
49
|
+
"inputs": {
|
|
50
|
+
"properties": {
|
|
51
|
+
"message": {"type": "string"}
|
|
52
|
+
},
|
|
53
|
+
"required": ["message"]
|
|
54
|
+
},
|
|
55
|
+
"outputs": {
|
|
56
|
+
"properties": {
|
|
57
|
+
"result": {"type": "string"}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"tags": ["utility"],
|
|
61
|
+
"tool_provider": {
|
|
62
|
+
"provider_type": "cli",
|
|
63
|
+
"name": "mock_cli_provider",
|
|
64
|
+
"command_name": sys.argv[0]
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "math",
|
|
69
|
+
"description": "Perform math operations",
|
|
70
|
+
"inputs": {
|
|
71
|
+
"properties": {
|
|
72
|
+
"operation": {"type": "string", "enum": ["add", "subtract"]},
|
|
73
|
+
"a": {"type": "number"},
|
|
74
|
+
"b": {"type": "number"}
|
|
75
|
+
},
|
|
76
|
+
"required": ["operation", "a", "b"]
|
|
77
|
+
},
|
|
78
|
+
"outputs": {
|
|
79
|
+
"properties": {
|
|
80
|
+
"result": {"type": "number"}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"tags": ["math"],
|
|
84
|
+
"tool_provider": {
|
|
85
|
+
"provider_type": "cli",
|
|
86
|
+
"name": "mock_cli_provider",
|
|
87
|
+
"command_name": sys.argv[0]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
print(json.dumps(tools_data))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Check for environment variables
|
|
96
|
+
if "--check-env" in sys.argv:
|
|
97
|
+
env_info = {}
|
|
98
|
+
# Check for specific test environment variables
|
|
99
|
+
test_vars = ['MY_API_KEY', 'TEST_VAR', 'CUSTOM_CONFIG']
|
|
100
|
+
for var in test_vars:
|
|
101
|
+
if var in os.environ:
|
|
102
|
+
env_info[var] = os.environ[var]
|
|
103
|
+
print(json.dumps(env_info))
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Handle tool execution
|
|
107
|
+
args = sys.argv[1:]
|
|
108
|
+
|
|
109
|
+
# Parse arguments
|
|
110
|
+
parsed_args = {}
|
|
111
|
+
i = 0
|
|
112
|
+
while i < len(args):
|
|
113
|
+
if args[i].startswith('--'):
|
|
114
|
+
key = args[i][2:]
|
|
115
|
+
if i + 1 < len(args) and not args[i + 1].startswith('--'):
|
|
116
|
+
value = args[i + 1]
|
|
117
|
+
# Try to parse as number
|
|
118
|
+
try:
|
|
119
|
+
if '.' in value:
|
|
120
|
+
value = float(value)
|
|
121
|
+
else:
|
|
122
|
+
value = int(value)
|
|
123
|
+
except ValueError:
|
|
124
|
+
pass # Keep as string
|
|
125
|
+
parsed_args[key] = value
|
|
126
|
+
i += 2
|
|
127
|
+
else:
|
|
128
|
+
parsed_args[key] = True
|
|
129
|
+
i += 1
|
|
130
|
+
else:
|
|
131
|
+
i += 1
|
|
132
|
+
|
|
133
|
+
# Simple tool implementations
|
|
134
|
+
if "message" in parsed_args:
|
|
135
|
+
# Echo tool
|
|
136
|
+
result = {"result": f"Echo: {parsed_args['message']}"}
|
|
137
|
+
print(json.dumps(result))
|
|
138
|
+
elif "operation" in parsed_args and "a" in parsed_args and "b" in parsed_args:
|
|
139
|
+
# Math tool
|
|
140
|
+
a = parsed_args["a"]
|
|
141
|
+
b = parsed_args["b"]
|
|
142
|
+
op = parsed_args["operation"]
|
|
143
|
+
|
|
144
|
+
if op == "add":
|
|
145
|
+
result = {"result": a + b}
|
|
146
|
+
elif op == "subtract":
|
|
147
|
+
result = {"result": a - b}
|
|
148
|
+
else:
|
|
149
|
+
print(f"Unknown operation: {op}", file=sys.stderr)
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
print(json.dumps(result))
|
|
153
|
+
elif "error" in parsed_args:
|
|
154
|
+
# Error simulation
|
|
155
|
+
print(f"Simulated error: {parsed_args['error']}", file=sys.stderr)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
else:
|
|
158
|
+
print("Unknown command or missing arguments", file=sys.stderr)
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
163
|
+
'''
|
|
164
|
+
|
|
165
|
+
# Create temporary script file
|
|
166
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
167
|
+
f.write(script_content)
|
|
168
|
+
script_path = f.name
|
|
169
|
+
|
|
170
|
+
# Make it executable on Unix systems
|
|
171
|
+
try:
|
|
172
|
+
os.chmod(script_path, 0o755)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass # Windows doesn't use executable permissions
|
|
175
|
+
|
|
176
|
+
yield script_path
|
|
177
|
+
|
|
178
|
+
# Cleanup
|
|
179
|
+
try:
|
|
180
|
+
os.unlink(script_path)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest_asyncio.fixture
|
|
186
|
+
def python_executable():
|
|
187
|
+
"""Get the Python executable path."""
|
|
188
|
+
return sys.executable
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.asyncio
|
|
192
|
+
async def test_register_provider_discovers_tools(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
193
|
+
"""Test that registering a provider discovers tools from command output."""
|
|
194
|
+
call_template = CliCallTemplate(
|
|
195
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
result = await transport.register_manual(None, call_template)
|
|
199
|
+
|
|
200
|
+
assert result is not None and result.manual is not None
|
|
201
|
+
tools = result.manual.tools
|
|
202
|
+
assert len(tools) == 2
|
|
203
|
+
assert tools[0].name == "echo"
|
|
204
|
+
assert tools[0].description == "Echo back the input"
|
|
205
|
+
assert tools[0].tags == ["utility"]
|
|
206
|
+
|
|
207
|
+
assert tools[1].name == "math"
|
|
208
|
+
assert tools[1].description == "Perform math operations"
|
|
209
|
+
assert tools[1].tags == ["math"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.asyncio
|
|
213
|
+
async def test_register_provider_missing_command_name(transport: CliCommunicationProtocol):
|
|
214
|
+
"""Test that registering a provider with empty command_name raises an error."""
|
|
215
|
+
call_template = CliCallTemplate(
|
|
216
|
+
command_name="" # Empty string instead of missing field
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
with pytest.raises(ValueError):
|
|
220
|
+
await transport.register_manual(None, call_template)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_register_provider_wrong_type(transport: CliCommunicationProtocol):
|
|
225
|
+
"""Test that registering a non-CLI call template raises an error."""
|
|
226
|
+
class DummyTemplate:
|
|
227
|
+
type = "http"
|
|
228
|
+
command_name = "echo"
|
|
229
|
+
|
|
230
|
+
with pytest.raises(ValueError):
|
|
231
|
+
await transport.register_manual(None, DummyTemplate())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.asyncio
|
|
235
|
+
async def test_call_tool_json_output(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
236
|
+
"""Test calling a tool that returns JSON output."""
|
|
237
|
+
call_template = CliCallTemplate(
|
|
238
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
result = await transport.call_tool(None, "echo", {"message": "Hello World"}, call_template)
|
|
242
|
+
|
|
243
|
+
assert isinstance(result, dict)
|
|
244
|
+
assert result["result"] == "Echo: Hello World"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_call_tool_math_operation(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
249
|
+
"""Test calling a math tool with numeric arguments."""
|
|
250
|
+
call_template = CliCallTemplate(
|
|
251
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
result = await transport.call_tool(None, "math", {"operation": "add", "a": 5, "b": 3}, call_template)
|
|
255
|
+
|
|
256
|
+
assert isinstance(result, dict)
|
|
257
|
+
assert result["result"] == 8
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
async def test_call_tool_error_handling(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
262
|
+
"""Test calling a tool that exits with an error returns stderr."""
|
|
263
|
+
call_template = CliCallTemplate(
|
|
264
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# This should trigger an error in the mock script
|
|
268
|
+
result = await transport.call_tool(None, "error_tool", {"error": "test error"}, call_template)
|
|
269
|
+
|
|
270
|
+
# Should return stderr content since exit code != 0
|
|
271
|
+
assert isinstance(result, str)
|
|
272
|
+
assert "Simulated error: test error" in result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_call_tool_missing_command_name(transport: CliCommunicationProtocol):
|
|
277
|
+
"""Test calling a tool with empty command_name raises an error."""
|
|
278
|
+
call_template = CliCallTemplate(
|
|
279
|
+
command_name="" # Empty string instead of missing field
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
with pytest.raises(ValueError):
|
|
283
|
+
await transport.call_tool(None, "some_tool", {}, call_template)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.mark.asyncio
|
|
287
|
+
async def test_call_tool_wrong_provider_type(transport: CliCommunicationProtocol):
|
|
288
|
+
"""Test calling a tool with wrong provider type."""
|
|
289
|
+
class DummyTemplate:
|
|
290
|
+
type = "http"
|
|
291
|
+
command_name = "echo"
|
|
292
|
+
|
|
293
|
+
with pytest.raises(ValueError):
|
|
294
|
+
await transport.call_tool(None, "some_tool", {}, DummyTemplate())
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_environment_variables(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
299
|
+
"""Test that custom environment variables are properly set."""
|
|
300
|
+
env_vars = {
|
|
301
|
+
"MY_API_KEY": "test-api-key-123",
|
|
302
|
+
"TEST_VAR": "test-value",
|
|
303
|
+
"CUSTOM_CONFIG": "config-data"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
call_template = CliCallTemplate(
|
|
307
|
+
command_name=f"{python_executable} {mock_cli_script}",
|
|
308
|
+
env_vars=env_vars
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Call the env check endpoint
|
|
312
|
+
result = await transport.call_tool(None, "check_env", {"check-env": True}, call_template)
|
|
313
|
+
|
|
314
|
+
assert isinstance(result, dict)
|
|
315
|
+
assert result["MY_API_KEY"] == "test-api-key-123"
|
|
316
|
+
assert result["TEST_VAR"] == "test-value"
|
|
317
|
+
assert result["CUSTOM_CONFIG"] == "config-data"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.asyncio
|
|
321
|
+
async def test_no_environment_variables(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
322
|
+
"""Test that no environment variables are set when env_vars is None."""
|
|
323
|
+
call_template = CliCallTemplate(
|
|
324
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
325
|
+
# env_vars=None by default
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Call the env check endpoint
|
|
329
|
+
result = await transport.call_tool(None, "check_env", {"check-env": True}, call_template)
|
|
330
|
+
|
|
331
|
+
assert isinstance(result, dict)
|
|
332
|
+
# Should be empty since no custom env vars were set
|
|
333
|
+
assert len(result) == 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@pytest.mark.asyncio
|
|
337
|
+
async def test_working_directory(transport: CliCommunicationProtocol, mock_cli_script, python_executable, tmp_path):
|
|
338
|
+
"""Test that working directory is properly set during command execution."""
|
|
339
|
+
# Create a test file in a specific directory
|
|
340
|
+
test_dir = tmp_path / "test_working_dir"
|
|
341
|
+
test_dir.mkdir()
|
|
342
|
+
test_file = test_dir / "current_dir.txt"
|
|
343
|
+
|
|
344
|
+
# Create a mock script that writes the current working directory to a file
|
|
345
|
+
script_content = '''
|
|
346
|
+
import os
|
|
347
|
+
import sys
|
|
348
|
+
|
|
349
|
+
if "--write-cwd" in sys.argv:
|
|
350
|
+
with open("current_dir.txt", "w") as f:
|
|
351
|
+
f.write(os.getcwd())
|
|
352
|
+
print("{\'status\': \'written\'}".replace("\'", '"'))
|
|
353
|
+
else:
|
|
354
|
+
print("{\'error\': \'unknown command\'}".replace("\'", '"'))
|
|
355
|
+
'''
|
|
356
|
+
|
|
357
|
+
working_dir_script = tmp_path / "working_dir_script.py"
|
|
358
|
+
working_dir_script.write_text(script_content)
|
|
359
|
+
|
|
360
|
+
call_template = CliCallTemplate(
|
|
361
|
+
command_name=f"{python_executable} {working_dir_script}",
|
|
362
|
+
working_dir=str(test_dir)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Call the tool which should write the current directory to a file
|
|
366
|
+
result = await transport.call_tool(None, "write_cwd", {"write-cwd": True}, call_template)
|
|
367
|
+
|
|
368
|
+
# Verify the result
|
|
369
|
+
assert isinstance(result, dict)
|
|
370
|
+
assert result["status"] == "written"
|
|
371
|
+
|
|
372
|
+
# Verify the file was created in the working directory and contains the correct path
|
|
373
|
+
assert test_file.exists()
|
|
374
|
+
written_cwd = test_file.read_text().strip()
|
|
375
|
+
|
|
376
|
+
# The written current working directory should be the test directory
|
|
377
|
+
assert os.path.abspath(written_cwd) == os.path.abspath(str(test_dir))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_no_working_directory(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
382
|
+
"""Test that commands work normally when no working directory is specified."""
|
|
383
|
+
call_template = CliCallTemplate(
|
|
384
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
385
|
+
# working_dir=None by default
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# This should work normally - calling the echo tool
|
|
389
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, call_template)
|
|
390
|
+
|
|
391
|
+
assert isinstance(result, dict)
|
|
392
|
+
assert result["result"] == "Echo: test"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@pytest.mark.asyncio
|
|
396
|
+
async def test_env_vars_and_working_dir_combined(transport: CliCommunicationProtocol, python_executable, tmp_path):
|
|
397
|
+
"""Test that both environment variables and working directory work together."""
|
|
398
|
+
# Create a test directory
|
|
399
|
+
test_dir = tmp_path / "combined_test_dir"
|
|
400
|
+
test_dir.mkdir()
|
|
401
|
+
|
|
402
|
+
# Create a script that checks both environment variable and writes current directory
|
|
403
|
+
script_content = '''
|
|
404
|
+
import os
|
|
405
|
+
import sys
|
|
406
|
+
import json
|
|
407
|
+
|
|
408
|
+
if "--combined-test" in sys.argv:
|
|
409
|
+
result = {
|
|
410
|
+
"current_dir": os.getcwd(),
|
|
411
|
+
"test_env_var": os.environ.get("TEST_COMBINED_VAR", "not_found"),
|
|
412
|
+
"status": "success"
|
|
413
|
+
}
|
|
414
|
+
print(json.dumps(result))
|
|
415
|
+
else:
|
|
416
|
+
print(json.dumps({"error": "unknown command"}))
|
|
417
|
+
'''
|
|
418
|
+
|
|
419
|
+
combined_script = tmp_path / "combined_test_script.py"
|
|
420
|
+
combined_script.write_text(script_content)
|
|
421
|
+
|
|
422
|
+
call_template = CliCallTemplate(
|
|
423
|
+
command_name=f"{python_executable} {combined_script}",
|
|
424
|
+
env_vars={"TEST_COMBINED_VAR": "test_value_123"},
|
|
425
|
+
working_dir=str(test_dir)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Call the tool
|
|
429
|
+
result = await transport.call_tool(None, "combined_test", {"combined-test": True}, call_template)
|
|
430
|
+
|
|
431
|
+
# Verify both environment variable and working directory are set correctly
|
|
432
|
+
assert isinstance(result, dict)
|
|
433
|
+
assert result["status"] == "success"
|
|
434
|
+
assert result["test_env_var"] == "test_value_123"
|
|
435
|
+
assert os.path.abspath(result["current_dir"]) == os.path.abspath(str(test_dir))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@pytest.mark.asyncio
|
|
439
|
+
async def test_argument_formatting():
|
|
440
|
+
"""Test that arguments are properly formatted for command line."""
|
|
441
|
+
transport = CliCommunicationProtocol()
|
|
442
|
+
|
|
443
|
+
# Test various argument types
|
|
444
|
+
args = {
|
|
445
|
+
"string_arg": "hello",
|
|
446
|
+
"number_arg": 42,
|
|
447
|
+
"float_arg": 3.14,
|
|
448
|
+
"bool_true": True,
|
|
449
|
+
"bool_false": False,
|
|
450
|
+
"list_arg": ["item1", "item2"]
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
formatted = transport._format_arguments(args)
|
|
454
|
+
|
|
455
|
+
# Check that arguments are properly formatted
|
|
456
|
+
assert "--string_arg" in formatted
|
|
457
|
+
assert "hello" in formatted
|
|
458
|
+
assert "--number_arg" in formatted
|
|
459
|
+
assert "42" in formatted
|
|
460
|
+
assert "--float_arg" in formatted
|
|
461
|
+
assert "3.14" in formatted
|
|
462
|
+
assert "--bool_true" in formatted
|
|
463
|
+
assert "--bool_false" not in formatted # False booleans should not appear
|
|
464
|
+
assert "--list_arg" in formatted
|
|
465
|
+
assert "item1" in formatted
|
|
466
|
+
assert "item2" in formatted
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@pytest.mark.asyncio
|
|
470
|
+
async def test_json_extraction_from_output():
|
|
471
|
+
"""Test extracting JSON from various output formats."""
|
|
472
|
+
transport = CliCommunicationProtocol()
|
|
473
|
+
|
|
474
|
+
# Test complete JSON output
|
|
475
|
+
output1 = '{"tools": [{"name": "test", "description": "Test tool", "tool_provider": {"provider_type": "cli", "name": "test_provider", "command_name": "test"}}]}'
|
|
476
|
+
manual1 = transport._extract_utcp_manual_from_output(output1, "test_provider")
|
|
477
|
+
assert manual1 is not None
|
|
478
|
+
assert len(manual1.tools) == 1
|
|
479
|
+
assert manual1.tools[0].name == "test"
|
|
480
|
+
|
|
481
|
+
# Test JSON within text output
|
|
482
|
+
output2 = '''
|
|
483
|
+
Starting CLI tool...
|
|
484
|
+
{"tools": [{"name": "embedded", "description": "Embedded tool", "tool_provider": {"provider_type": "cli", "name": "test_provider", "command_name": "test"}}]}
|
|
485
|
+
Process completed.
|
|
486
|
+
'''
|
|
487
|
+
manual2 = transport._extract_utcp_manual_from_output(output2, "test_provider")
|
|
488
|
+
assert manual2 is not None
|
|
489
|
+
assert len(manual2.tools) == 1
|
|
490
|
+
assert manual2.tools[0].name == "embedded"
|
|
491
|
+
|
|
492
|
+
# Test single tool definition
|
|
493
|
+
output3 = '{"name": "single", "description": "Single tool", "tool_provider": {"provider_type": "cli", "name": "test_provider", "command_name": "test"}}'
|
|
494
|
+
manual3 = transport._extract_utcp_manual_from_output(output3, "test_provider")
|
|
495
|
+
assert manual3 is not None
|
|
496
|
+
assert len(manual3.tools) == 1
|
|
497
|
+
assert manual3.tools[0].name == "single"
|
|
498
|
+
|
|
499
|
+
# Test no valid JSON
|
|
500
|
+
output4 = "No JSON here, just plain text"
|
|
501
|
+
manual4 = transport._extract_utcp_manual_from_output(output4, "test_provider")
|
|
502
|
+
assert manual4 is None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@pytest.mark.asyncio
|
|
506
|
+
async def test_deregister_provider(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
|
|
507
|
+
"""Test deregistering a CLI provider."""
|
|
508
|
+
call_template = CliCallTemplate(
|
|
509
|
+
command_name=f"{python_executable} {mock_cli_script}"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Register and then deregister (should not raise any errors)
|
|
513
|
+
await transport.register_manual(None, call_template)
|
|
514
|
+
await transport.deregister_manual(None, call_template)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@pytest.mark.asyncio
|
|
518
|
+
async def test_close_transport(transport: CliCommunicationProtocol):
|
|
519
|
+
"""Test closing the transport."""
|
|
520
|
+
# Should not raise any errors (only if close() is implemented)
|
|
521
|
+
if hasattr(transport, "close") and asyncio.iscoroutinefunction(getattr(transport, "close")):
|
|
522
|
+
await transport.close()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@pytest.mark.asyncio
|
|
526
|
+
async def test_command_execution_timeout(python_executable, tmp_path):
|
|
527
|
+
"""Test that command execution respects timeout."""
|
|
528
|
+
transport = CliCommunicationProtocol()
|
|
529
|
+
|
|
530
|
+
# Create a Python script that sleeps for a long time
|
|
531
|
+
sleep_script_content = '''
|
|
532
|
+
import time
|
|
533
|
+
import sys
|
|
534
|
+
|
|
535
|
+
if "--sleep" in sys.argv:
|
|
536
|
+
time.sleep(10) # Sleep for 10 seconds
|
|
537
|
+
print("This should not be printed due to timeout")
|
|
538
|
+
else:
|
|
539
|
+
print("Unknown command")
|
|
540
|
+
sys.exit(1)
|
|
541
|
+
'''
|
|
542
|
+
|
|
543
|
+
sleep_script = tmp_path / "sleep_script.py"
|
|
544
|
+
sleep_script.write_text(sleep_script_content)
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
command = [python_executable, str(sleep_script), "--sleep"]
|
|
548
|
+
env = os.environ.copy()
|
|
549
|
+
|
|
550
|
+
with pytest.raises(asyncio.TimeoutError): # Should raise TimeoutError
|
|
551
|
+
await transport._execute_command(command, env, timeout=1.0, working_dir=str(tmp_path))
|
|
552
|
+
|
|
553
|
+
except Exception as e:
|
|
554
|
+
# If the specific timeout doesn't work, just ensure some exception is raised
|
|
555
|
+
# and it's related to timing out
|
|
556
|
+
assert "timeout" in str(e).lower() or isinstance(e, asyncio.TimeoutError)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@pytest.mark.asyncio
|
|
560
|
+
async def test_mixed_output_formats(transport: CliCommunicationProtocol, python_executable):
|
|
561
|
+
"""Test handling of mixed output formats (text and JSON)."""
|
|
562
|
+
# Create a simple script that outputs mixed content
|
|
563
|
+
script_content = '''
|
|
564
|
+
import sys
|
|
565
|
+
print("Starting tool execution...")
|
|
566
|
+
print('{"result": "success", "value": 42}')
|
|
567
|
+
print("Tool execution completed.")
|
|
568
|
+
'''
|
|
569
|
+
|
|
570
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
571
|
+
f.write(script_content)
|
|
572
|
+
script_path = f.name
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
call_template = CliCallTemplate(
|
|
576
|
+
command_name=f"{python_executable} {script_path}"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
result = await transport.call_tool(None, "mixed_tool", {}, call_template)
|
|
580
|
+
|
|
581
|
+
# Should return the JSON part since command succeeds (exit code 0)
|
|
582
|
+
# But the output contains both text and JSON
|
|
583
|
+
assert isinstance(result, str) # Will be text since full output isn't valid JSON
|
|
584
|
+
assert "Starting tool execution..." in result
|
|
585
|
+
assert '{"result": "success", "value": 42}' in result
|
|
586
|
+
|
|
587
|
+
finally:
|
|
588
|
+
try:
|
|
589
|
+
os.unlink(script_path)
|
|
590
|
+
except Exception:
|
|
591
|
+
pass
|