hud-python 0.3.5__py3-none-any.whl → 0.4.1__py3-none-any.whl

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.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +15 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +370 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +379 -0
  45. hud/clients/fastmcp.py +222 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.1.dist-info/METADATA +476 -0
  126. hud_python-0.4.1.dist-info/RECORD +132 -0
  127. hud_python-0.4.1.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/WHEEL +0 -0
@@ -1,3 +1,3 @@
1
- from __future__ import annotations
2
-
3
- __all__ = []
1
+ from __future__ import annotations
2
+
3
+ __all__ = []
@@ -0,0 +1,282 @@
1
+ """Tests for base tool classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from fastmcp import FastMCP
10
+ from mcp.types import ContentBlock, TextContent
11
+
12
+ from hud.tools.base import _INTERNAL_PREFIX, BaseHub, BaseTool
13
+
14
+
15
+ class MockTool(BaseTool):
16
+ """Mock tool for testing."""
17
+
18
+ async def __call__(self, param1: Any = None, param2: Any = None) -> list[ContentBlock]:
19
+ """Execute the mock tool."""
20
+ kwargs = {"param1": param1, "param2": param2}
21
+ return [TextContent(type="text", text=f"Mock result: {kwargs}")]
22
+
23
+
24
+ class TestBaseTool:
25
+ """Test BaseTool class."""
26
+
27
+ def test_init_with_defaults(self):
28
+ """Test BaseTool initialization with default values."""
29
+
30
+ class TestTool(BaseTool):
31
+ """A test tool."""
32
+
33
+ async def __call__(self, **kwargs: Any) -> list[ContentBlock]:
34
+ return []
35
+
36
+ tool = TestTool()
37
+
38
+ # Check auto-generated values
39
+ assert tool.name == "test"
40
+ assert tool.title == "Test"
41
+ assert tool.description == "A test tool."
42
+ assert tool.env is None
43
+ assert tool.__name__ == "test"
44
+ assert tool.__doc__ == "A test tool."
45
+
46
+ def test_init_with_custom_values(self):
47
+ """Test BaseTool initialization with custom values."""
48
+
49
+ env = {"key": "value"}
50
+ tool = MockTool(
51
+ env=env, name="custom_tool", title="Custom Tool", description="Custom description"
52
+ )
53
+
54
+ assert tool.env == env
55
+ assert tool.name == "custom_tool"
56
+ assert tool.title == "Custom Tool"
57
+ assert tool.description == "Custom description"
58
+ assert tool.__name__ == "custom_tool"
59
+ assert tool.__doc__ == "Custom description"
60
+
61
+ def test_init_no_docstring(self):
62
+ """Test BaseTool with no docstring."""
63
+
64
+ class NoDocTool(BaseTool):
65
+ async def __call__(self, **kwargs: Any) -> list[ContentBlock]:
66
+ return []
67
+
68
+ tool = NoDocTool()
69
+ assert tool.description is None
70
+ assert not hasattr(tool, "__doc__") or tool.__doc__ is None
71
+
72
+ def test_register(self):
73
+ """Test registering tool with FastMCP server."""
74
+
75
+ server = MagicMock(spec=FastMCP)
76
+ tool = MockTool(name="test_tool")
77
+
78
+ # Test register returns self for chaining
79
+ result = tool.register(server, tag="test")
80
+
81
+ assert result is tool
82
+ server.add_tool.assert_called_once()
83
+
84
+ # Check the tool passed has correct name
85
+ call_args = server.add_tool.call_args
86
+ assert call_args[1]["tag"] == "test"
87
+
88
+ def test_mcp_property_cached(self):
89
+ """Test mcp property returns cached FunctionTool."""
90
+
91
+ tool = MockTool(name="cached_tool", title="Cached Tool", description="Test caching")
92
+
93
+ # First access creates the tool
94
+ mcp_tool1 = tool.mcp
95
+ assert hasattr(tool, "_mcp_tool")
96
+
97
+ # Second access returns cached
98
+ mcp_tool2 = tool.mcp
99
+ assert mcp_tool1 is mcp_tool2
100
+
101
+ def test_mcp_property_attributes(self):
102
+ """Test mcp property creates FunctionTool with correct attributes."""
103
+
104
+ tool = MockTool(
105
+ name="mcp_test", title="MCP Test Tool", description="Testing MCP conversion"
106
+ )
107
+
108
+ with patch("fastmcp.tools.FunctionTool") as MockFunctionTool:
109
+ mock_ft = MagicMock()
110
+ MockFunctionTool.from_function.return_value = mock_ft
111
+
112
+ result = tool.mcp
113
+
114
+ # The wrapper function is passed, not the tool itself
115
+ MockFunctionTool.from_function.assert_called_once()
116
+ call_args = MockFunctionTool.from_function.call_args
117
+
118
+ # Check that the correct parameters were passed
119
+ assert call_args[1]["name"] == "mcp_test"
120
+ assert call_args[1]["title"] == "MCP Test Tool"
121
+ assert call_args[1]["description"] == "Testing MCP conversion"
122
+ assert result is mock_ft
123
+
124
+
125
+ class TestBaseHub:
126
+ """Test BaseHub class."""
127
+
128
+ def test_init_basic(self):
129
+ """Test BaseHub basic initialization."""
130
+
131
+ hub = BaseHub("test_hub")
132
+
133
+ assert hub._prefix_fn("tool") == f"{_INTERNAL_PREFIX}tool"
134
+ assert hasattr(hub, "_tool_manager")
135
+ assert hasattr(hub, "_resource_manager")
136
+
137
+ def test_init_with_env(self):
138
+ """Test BaseHub initialization with environment."""
139
+
140
+ env = {"test": "env"}
141
+ hub = BaseHub("test_hub", env=env, title="Test Hub", description="A test hub")
142
+
143
+ assert hub.env == env
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_dispatcher_tool_registered(self):
147
+ """Test that dispatcher tool is registered on init."""
148
+
149
+ hub = BaseHub("dispatcher_test")
150
+
151
+ # Check dispatcher tool exists
152
+ tools = hub._tool_manager._tools
153
+ assert "dispatcher_test" in tools
154
+
155
+ # Test calling dispatcher with internal tool
156
+ @hub.tool("internal_func")
157
+ async def internal_func(value: int) -> Any:
158
+ return [TextContent(type="text", text=f"Internal: {value}")]
159
+
160
+ # Call dispatcher
161
+ result = await hub._tool_manager.call_tool(
162
+ "dispatcher_test", {"name": "internal_func", "arguments": {"value": 42}}
163
+ )
164
+
165
+ # ToolResult has content attribute
166
+ assert len(result.content) == 1
167
+ assert isinstance(result.content[0], TextContent)
168
+ assert result.content[0].text == "Internal: 42"
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_functions_catalogue_resource(self):
172
+ """Test functions catalogue resource lists internal tools."""
173
+
174
+ hub = BaseHub("catalogue_test")
175
+
176
+ # Add some internal tools
177
+ @hub.tool("func1")
178
+ async def func1() -> Any:
179
+ return []
180
+
181
+ @hub.tool("func2")
182
+ async def func2() -> Any:
183
+ return []
184
+
185
+ # Get the catalogue resource
186
+ resources = hub._resource_manager._resources
187
+ catalogue_uri = "file:///catalogue_test/functions"
188
+ assert catalogue_uri in resources
189
+
190
+ # Call the resource
191
+ resource = resources[catalogue_uri]
192
+ content = await resource.read()
193
+ # The resource returns JSON content, parse it
194
+ import json
195
+
196
+ funcs = json.loads(content)
197
+
198
+ assert sorted(funcs) == ["func1", "func2"]
199
+
200
+ def test_tool_decorator_with_name(self):
201
+ """Test tool decorator with explicit name."""
202
+
203
+ hub = BaseHub("decorator_test")
204
+
205
+ # Test positional name
206
+ decorator = hub.tool("my_tool")
207
+ assert callable(decorator)
208
+
209
+ # Test keyword name
210
+ decorator2 = hub.tool(name="my_tool2", tags={"test"})
211
+ assert callable(decorator2)
212
+
213
+ def test_tool_decorator_without_name(self):
214
+ """Test tool decorator without name."""
215
+
216
+ hub = BaseHub("decorator_test")
217
+
218
+ # Test bare decorator
219
+ decorator = hub.tool()
220
+ assert callable(decorator)
221
+
222
+ # Test decorator with only kwargs
223
+ decorator2 = hub.tool(tags={"test"})
224
+ assert callable(decorator2)
225
+
226
+ def test_tool_decorator_phase2(self):
227
+ """Test tool decorator phase 2 (when function is passed)."""
228
+
229
+ hub = BaseHub("phase2_test")
230
+
231
+ async def my_func() -> Any:
232
+ return []
233
+
234
+ # Simulate phase 2 of decorator application
235
+ with patch.object(FastMCP, "tool") as mock_super_tool:
236
+ mock_super_tool.return_value = my_func
237
+
238
+ # Call with function directly (phase 2)
239
+ result = hub.tool(my_func, tags={"test"})
240
+
241
+ assert result is my_func
242
+ mock_super_tool.assert_called_once_with(my_func, tags={"test"})
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_list_tools_hides_internal(self):
246
+ """Test _list_tools hides internal tools."""
247
+
248
+ hub = BaseHub("list_test")
249
+
250
+ # Add public tool (use @hub.tool() without prefix for public tools in FastMCP)
251
+ from fastmcp.tools import Tool
252
+
253
+ async def public_tool() -> Any:
254
+ return []
255
+
256
+ public_tool_obj = Tool.from_function(public_tool)
257
+ hub.add_tool(public_tool_obj)
258
+
259
+ # Add internal tool
260
+ @hub.tool("internal_tool")
261
+ async def internal_tool() -> Any:
262
+ return []
263
+
264
+ # List tools should only show public
265
+ tools = await hub._list_tools()
266
+ tool_names = [t.name for t in tools]
267
+
268
+ assert "public_tool" in tool_names
269
+ assert "internal_tool" not in tool_names
270
+ assert f"{_INTERNAL_PREFIX}internal_tool" not in tool_names
271
+
272
+ def test_resource_and_prompt_passthrough(self):
273
+ """Test that resource and prompt decorators pass through."""
274
+
275
+ hub = BaseHub("passthrough_test")
276
+
277
+ # These should be inherited from FastMCP
278
+ assert hasattr(hub, "resource")
279
+ assert hasattr(hub, "prompt")
280
+ # Check they're the same methods (by name)
281
+ assert hub.resource.__name__ == FastMCP.resource.__name__
282
+ assert hub.prompt.__name__ == FastMCP.prompt.__name__
@@ -1,152 +1,158 @@
1
- """Tests for bash tool."""
2
-
3
- from __future__ import annotations
4
-
5
- from unittest.mock import AsyncMock, MagicMock, patch
6
-
7
- import pytest
8
-
9
- from hud.tools.base import ToolResult
10
- from hud.tools.bash import BashTool, ToolError, _BashSession
11
-
12
-
13
- class TestBashSession:
14
- """Tests for _BashSession."""
15
-
16
- @pytest.mark.asyncio
17
- async def test_session_start(self):
18
- """Test starting a bash session."""
19
- session = _BashSession()
20
- assert session._started is False
21
-
22
- with patch("asyncio.create_subprocess_shell") as mock_create:
23
- mock_process = MagicMock()
24
- mock_create.return_value = mock_process
25
-
26
- await session.start()
27
-
28
- assert session._started is True
29
- assert session._process == mock_process
30
- mock_create.assert_called_once()
31
-
32
- def test_session_stop_not_started(self):
33
- """Test stopping a session that hasn't started."""
34
- session = _BashSession()
35
-
36
- with pytest.raises(ToolError) as exc_info:
37
- session.stop()
38
-
39
- assert "Session has not started" in str(exc_info.value)
40
-
41
- @pytest.mark.asyncio
42
- async def test_session_run_not_started(self):
43
- """Test running command on a session that hasn't started."""
44
- session = _BashSession()
45
-
46
- with pytest.raises(ToolError) as exc_info:
47
- await session.run("echo test")
48
-
49
- assert "Session has not started" in str(exc_info.value)
50
-
51
- @pytest.mark.asyncio
52
- async def test_session_run_success(self):
53
- """Test successful command execution."""
54
- session = _BashSession()
55
- session._started = True
56
-
57
- # Mock process
58
- mock_process = MagicMock()
59
- mock_process.returncode = None
60
- mock_process.stdin = MagicMock()
61
- mock_process.stdin.write = MagicMock()
62
- mock_process.stdin.drain = AsyncMock()
63
- mock_process.stdout = MagicMock()
64
- mock_process.stdout.readuntil = AsyncMock(return_value=b"Hello World\n<<exit>>\n")
65
- mock_process.stderr = MagicMock()
66
- mock_process.stderr.read = AsyncMock(return_value=b"")
67
-
68
- session._process = mock_process
69
-
70
- result = await session.run("echo Hello World")
71
-
72
- assert result.output == "Hello World\n"
73
- assert result.error == ""
74
-
75
-
76
- class TestBashTool:
77
- """Tests for BashTool."""
78
-
79
- def test_bash_tool_init(self):
80
- """Test BashTool initialization."""
81
- tool = BashTool()
82
- assert tool._session is None
83
-
84
- @pytest.mark.asyncio
85
- async def test_call_with_command(self):
86
- """Test calling tool with a command."""
87
- tool = BashTool()
88
-
89
- # Mock session
90
- mock_session = MagicMock()
91
- mock_session.run = AsyncMock(return_value=ToolResult(output="test output"))
92
-
93
- # Mock _BashSession creation
94
- with patch("hud.tools.bash._BashSession") as mock_session_class:
95
- mock_session_class.return_value = mock_session
96
- mock_session.start = AsyncMock()
97
-
98
- result = await tool(command="echo test")
99
-
100
- assert isinstance(result, ToolResult)
101
- assert result.output == "test output"
102
- mock_session.start.assert_called_once()
103
- mock_session.run.assert_called_once_with("echo test")
104
-
105
- @pytest.mark.asyncio
106
- async def test_call_restart(self):
107
- """Test restarting the tool."""
108
- tool = BashTool()
109
-
110
- # Set up existing session
111
- old_session = MagicMock()
112
- old_session.stop = MagicMock()
113
- tool._session = old_session
114
-
115
- # Mock new session
116
- new_session = MagicMock()
117
- new_session.start = AsyncMock()
118
-
119
- with patch("hud.tools.bash._BashSession", return_value=new_session):
120
- result = await tool(restart=True)
121
-
122
- assert isinstance(result, ToolResult)
123
- assert result.system == "tool has been restarted."
124
- old_session.stop.assert_called_once()
125
- new_session.start.assert_called_once()
126
- assert tool._session == new_session
127
-
128
- @pytest.mark.asyncio
129
- async def test_call_no_command_error(self):
130
- """Test calling without command raises error."""
131
- tool = BashTool()
132
-
133
- with pytest.raises(ToolError) as exc_info:
134
- await tool()
135
-
136
- assert "no command provided" in str(exc_info.value)
137
-
138
- @pytest.mark.asyncio
139
- async def test_call_with_existing_session(self):
140
- """Test calling with an existing session."""
141
- tool = BashTool()
142
-
143
- # Set up existing session
144
- existing_session = MagicMock()
145
- existing_session.run = AsyncMock(return_value=ToolResult(output="result"))
146
- tool._session = existing_session
147
-
148
- result = await tool(command="ls")
149
-
150
- assert isinstance(result, ToolResult)
151
- assert result.output == "result"
152
- existing_session.run.assert_called_once_with("ls")
1
+ """Tests for bash tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from hud.tools.bash import BashTool, ContentResult, ToolError, _BashSession
10
+ from hud.tools.types import TextContent
11
+
12
+
13
+ class TestBashSession:
14
+ """Tests for _BashSession."""
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_session_start(self):
18
+ """Test starting a bash session."""
19
+ session = _BashSession()
20
+ assert session._started is False
21
+
22
+ with patch("asyncio.create_subprocess_shell") as mock_create:
23
+ mock_process = MagicMock()
24
+ mock_create.return_value = mock_process
25
+
26
+ await session.start()
27
+
28
+ assert session._started is True
29
+ assert session._process == mock_process
30
+ mock_create.assert_called_once()
31
+
32
+ def test_session_stop_not_started(self):
33
+ """Test stopping a session that hasn't started."""
34
+ session = _BashSession()
35
+
36
+ with pytest.raises(ToolError) as exc_info:
37
+ session.stop()
38
+
39
+ assert "Session has not started" in str(exc_info.value)
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_session_run_not_started(self):
43
+ """Test running command on a session that hasn't started."""
44
+ session = _BashSession()
45
+
46
+ with pytest.raises(ToolError) as exc_info:
47
+ await session.run("echo test")
48
+
49
+ assert "Session has not started" in str(exc_info.value)
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_session_run_success(self):
53
+ """Test successful command execution."""
54
+ session = _BashSession()
55
+ session._started = True
56
+
57
+ # Mock process
58
+ mock_process = MagicMock()
59
+ mock_process.returncode = None
60
+ mock_process.stdin = MagicMock()
61
+ mock_process.stdin.write = MagicMock()
62
+ mock_process.stdin.drain = AsyncMock()
63
+ mock_process.stdout = MagicMock()
64
+ mock_process.stdout.readuntil = AsyncMock(return_value=b"Hello World\n<<exit>>\n")
65
+ mock_process.stderr = MagicMock()
66
+ mock_process.stderr.read = AsyncMock(return_value=b"")
67
+
68
+ session._process = mock_process
69
+
70
+ result = await session.run("echo Hello World")
71
+
72
+ assert result.output == "Hello World\n"
73
+ assert result.error == ""
74
+
75
+
76
+ class TestBashTool:
77
+ """Tests for BashTool."""
78
+
79
+ def test_bash_tool_init(self):
80
+ """Test BashTool initialization."""
81
+ tool = BashTool()
82
+ assert tool.session is None
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_call_with_command(self):
86
+ """Test calling tool with a command."""
87
+ tool = BashTool()
88
+
89
+ # Mock session
90
+ mock_session = MagicMock()
91
+ mock_session.run = AsyncMock(return_value=ContentResult(output="test output"))
92
+
93
+ # Mock _BashSession creation
94
+ with patch("hud.tools.bash._BashSession") as mock_session_class:
95
+ mock_session_class.return_value = mock_session
96
+ mock_session.start = AsyncMock()
97
+
98
+ result = await tool(command="echo test")
99
+
100
+ assert isinstance(result, list)
101
+ assert len(result) == 1
102
+ assert isinstance(result[0], TextContent)
103
+ assert result[0].text == "test output"
104
+ mock_session.start.assert_called_once()
105
+ mock_session.run.assert_called_once_with("echo test")
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_call_restart(self):
109
+ """Test restarting the tool."""
110
+ tool = BashTool()
111
+
112
+ # Set up existing session
113
+ old_session = MagicMock()
114
+ old_session.stop = MagicMock()
115
+ tool.session = old_session
116
+
117
+ # Mock new session
118
+ new_session = MagicMock()
119
+ new_session.start = AsyncMock()
120
+
121
+ with patch("hud.tools.bash._BashSession", return_value=new_session):
122
+ result = await tool(restart=True)
123
+
124
+ assert isinstance(result, list)
125
+ assert len(result) == 1
126
+ assert isinstance(result[0], TextContent)
127
+ assert result[0].text == "Bash session restarted."
128
+ old_session.stop.assert_called_once()
129
+ new_session.start.assert_called_once()
130
+ assert tool.session == new_session
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_call_no_command_error(self):
134
+ """Test calling without command raises error."""
135
+ tool = BashTool()
136
+
137
+ with pytest.raises(ToolError) as exc_info:
138
+ await tool()
139
+
140
+ assert str(exc_info.value) == "No command provided."
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_call_with_existing_session(self):
144
+ """Test calling with an existing session."""
145
+ tool = BashTool()
146
+
147
+ # Set up existing session
148
+ existing_session = MagicMock()
149
+ existing_session.run = AsyncMock(return_value=ContentResult(output="result"))
150
+ tool.session = existing_session
151
+
152
+ result = await tool(command="ls")
153
+
154
+ assert isinstance(result, list)
155
+ assert len(result) == 1
156
+ assert isinstance(result[0], TextContent)
157
+ assert result[0].text == "result"
158
+ existing_session.run.assert_called_once_with("ls")