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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [utcp.plugins]
2
+ cli = utcp_cli:register
@@ -0,0 +1,11 @@
1
+ pydantic>=2.0
2
+ pyyaml>=6.0
3
+ utcp>=1.0
4
+
5
+ [dev]
6
+ build
7
+ pytest
8
+ pytest-asyncio
9
+ pytest-cov
10
+ coverage
11
+ twine
@@ -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