hud-python 0.4.11__py3-none-any.whl → 0.4.13__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.
- hud/__main__.py +8 -0
- hud/agents/base.py +7 -8
- hud/agents/langchain.py +2 -2
- hud/agents/tests/test_openai.py +3 -1
- hud/cli/__init__.py +114 -52
- hud/cli/build.py +121 -71
- hud/cli/debug.py +2 -2
- hud/cli/{mcp_server.py → dev.py} +101 -38
- hud/cli/eval.py +175 -90
- hud/cli/init.py +442 -64
- hud/cli/list_func.py +72 -71
- hud/cli/pull.py +1 -2
- hud/cli/push.py +35 -23
- hud/cli/remove.py +35 -41
- hud/cli/tests/test_analyze.py +2 -1
- hud/cli/tests/test_analyze_metadata.py +42 -49
- hud/cli/tests/test_build.py +28 -52
- hud/cli/tests/test_cursor.py +1 -1
- hud/cli/tests/test_debug.py +1 -1
- hud/cli/tests/test_list_func.py +75 -64
- hud/cli/tests/test_main_module.py +30 -0
- hud/cli/tests/test_mcp_server.py +3 -3
- hud/cli/tests/test_pull.py +30 -61
- hud/cli/tests/test_push.py +70 -89
- hud/cli/tests/test_registry.py +36 -38
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/utils/__init__.py +1 -0
- hud/cli/{docker_utils.py → utils/docker.py} +36 -0
- hud/cli/{env_utils.py → utils/environment.py} +7 -7
- hud/cli/{interactive.py → utils/interactive.py} +91 -19
- hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
- hud/cli/{registry.py → utils/registry.py} +28 -30
- hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
- hud/cli/utils/runner.py +134 -0
- hud/cli/utils/server.py +250 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +5 -13
- hud/clients/mcp_use.py +6 -10
- hud/server/server.py +35 -5
- hud/shared/exceptions.py +11 -0
- hud/shared/tests/test_exceptions.py +22 -0
- hud/telemetry/tests/__init__.py +0 -0
- hud/telemetry/tests/test_replay.py +40 -0
- hud/telemetry/tests/test_trace.py +63 -0
- hud/tools/base.py +20 -3
- hud/tools/computer/hud.py +15 -6
- hud/tools/executors/tests/test_base_executor.py +27 -0
- hud/tools/response.py +12 -8
- hud/tools/tests/test_response.py +60 -0
- hud/tools/tests/test_tools_init.py +49 -0
- hud/utils/design.py +19 -8
- hud/utils/mcp.py +17 -5
- hud/utils/tests/test_mcp.py +112 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/METADATA +16 -13
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/RECORD +62 -52
- hud/cli/runner.py +0 -160
- /hud/cli/{cursor.py → utils/cursor.py} +0 -0
- /hud/cli/{utils.py → utils/logging.py} +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/WHEEL +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/licenses/LICENSE +0 -0
hud/cli/tests/test_push.py
CHANGED
|
@@ -5,12 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import json
|
|
7
7
|
import subprocess
|
|
8
|
-
from datetime import datetime
|
|
9
|
-
from pathlib import Path
|
|
10
8
|
from unittest import mock
|
|
11
9
|
|
|
12
10
|
import pytest
|
|
13
|
-
import requests
|
|
14
11
|
import typer
|
|
15
12
|
import yaml
|
|
16
13
|
|
|
@@ -30,7 +27,7 @@ class TestGetDockerUsername:
|
|
|
30
27
|
# Create mock Docker config
|
|
31
28
|
docker_dir = tmp_path / ".docker"
|
|
32
29
|
docker_dir.mkdir()
|
|
33
|
-
|
|
30
|
+
|
|
34
31
|
config_file = docker_dir / "config.json"
|
|
35
32
|
config = {
|
|
36
33
|
"auths": {
|
|
@@ -40,37 +37,31 @@ class TestGetDockerUsername:
|
|
|
40
37
|
}
|
|
41
38
|
}
|
|
42
39
|
config_file.write_text(json.dumps(config))
|
|
43
|
-
|
|
40
|
+
|
|
44
41
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
45
42
|
username = get_docker_username()
|
|
46
|
-
|
|
43
|
+
|
|
47
44
|
assert username == "testuser"
|
|
48
45
|
|
|
49
46
|
def test_get_username_no_config(self, tmp_path):
|
|
50
47
|
"""Test when no Docker config exists."""
|
|
51
48
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
52
49
|
username = get_docker_username()
|
|
53
|
-
|
|
50
|
+
|
|
54
51
|
assert username is None
|
|
55
52
|
|
|
56
53
|
def test_get_username_token_auth(self, tmp_path):
|
|
57
54
|
"""Test skipping token-based auth."""
|
|
58
55
|
docker_dir = tmp_path / ".docker"
|
|
59
56
|
docker_dir.mkdir()
|
|
60
|
-
|
|
57
|
+
|
|
61
58
|
config_file = docker_dir / "config.json"
|
|
62
|
-
config = {
|
|
63
|
-
"auths": {
|
|
64
|
-
"docker.io": {
|
|
65
|
-
"auth": base64.b64encode(b"token:xyz").decode()
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
59
|
+
config = {"auths": {"docker.io": {"auth": base64.b64encode(b"token:xyz").decode()}}}
|
|
69
60
|
config_file.write_text(json.dumps(config))
|
|
70
|
-
|
|
61
|
+
|
|
71
62
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
72
63
|
username = get_docker_username()
|
|
73
|
-
|
|
64
|
+
|
|
74
65
|
assert username is None
|
|
75
66
|
|
|
76
67
|
@mock.patch("subprocess.run")
|
|
@@ -78,20 +69,20 @@ class TestGetDockerUsername:
|
|
|
78
69
|
"""Test getting username from credential helper."""
|
|
79
70
|
docker_dir = tmp_path / ".docker"
|
|
80
71
|
docker_dir.mkdir()
|
|
81
|
-
|
|
72
|
+
|
|
82
73
|
config_file = docker_dir / "config.json"
|
|
83
74
|
config = {"credsStore": "desktop"}
|
|
84
75
|
config_file.write_text(json.dumps(config))
|
|
85
|
-
|
|
76
|
+
|
|
86
77
|
# Mock credential helper calls
|
|
87
78
|
mock_run.side_effect = [
|
|
88
79
|
mock.Mock(returncode=0, stdout='{"https://index.docker.io/v1/": "creds"}'),
|
|
89
|
-
mock.Mock(returncode=0, stdout='{"Username": "helperuser", "Secret": "pass"}')
|
|
80
|
+
mock.Mock(returncode=0, stdout='{"Username": "helperuser", "Secret": "pass"}'),
|
|
90
81
|
]
|
|
91
|
-
|
|
82
|
+
|
|
92
83
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
93
84
|
username = get_docker_username()
|
|
94
|
-
|
|
85
|
+
|
|
95
86
|
assert username == "helperuser"
|
|
96
87
|
|
|
97
88
|
|
|
@@ -101,16 +92,9 @@ class TestGetDockerImageLabels:
|
|
|
101
92
|
@mock.patch("subprocess.run")
|
|
102
93
|
def test_get_labels_success(self, mock_run):
|
|
103
94
|
"""Test successfully getting image labels."""
|
|
104
|
-
labels = {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
mock_run.return_value = mock.Mock(
|
|
109
|
-
returncode=0,
|
|
110
|
-
stdout=json.dumps(labels),
|
|
111
|
-
stderr=""
|
|
112
|
-
)
|
|
113
|
-
|
|
95
|
+
labels = {"org.hud.manifest.head": "abc123", "org.hud.version": "1.0.0"}
|
|
96
|
+
mock_run.return_value = mock.Mock(returncode=0, stdout=json.dumps(labels), stderr="")
|
|
97
|
+
|
|
114
98
|
result = get_docker_image_labels("test:latest")
|
|
115
99
|
assert result == labels
|
|
116
100
|
|
|
@@ -118,7 +102,7 @@ class TestGetDockerImageLabels:
|
|
|
118
102
|
def test_get_labels_failure(self, mock_run):
|
|
119
103
|
"""Test handling failure to get labels."""
|
|
120
104
|
mock_run.side_effect = Exception("Command failed")
|
|
121
|
-
|
|
105
|
+
|
|
122
106
|
result = get_docker_image_labels("test:latest")
|
|
123
107
|
assert result == {}
|
|
124
108
|
|
|
@@ -131,10 +115,10 @@ class TestPushEnvironment:
|
|
|
131
115
|
"""Test pushing when no lock file exists."""
|
|
132
116
|
mock_design = mock.Mock()
|
|
133
117
|
mock_design_class.return_value = mock_design
|
|
134
|
-
|
|
118
|
+
|
|
135
119
|
with pytest.raises(typer.Exit) as exc_info:
|
|
136
120
|
push_environment(str(tmp_path))
|
|
137
|
-
|
|
121
|
+
|
|
138
122
|
assert exc_info.value.exit_code == 1
|
|
139
123
|
mock_design.error.assert_called()
|
|
140
124
|
|
|
@@ -145,14 +129,14 @@ class TestPushEnvironment:
|
|
|
145
129
|
mock_design = mock.Mock()
|
|
146
130
|
mock_design_class.return_value = mock_design
|
|
147
131
|
mock_settings.api_key = None
|
|
148
|
-
|
|
132
|
+
|
|
149
133
|
# Create lock file
|
|
150
134
|
lock_file = tmp_path / "hud.lock.yaml"
|
|
151
135
|
lock_file.write_text(yaml.dump({"image": "test:latest"}))
|
|
152
|
-
|
|
136
|
+
|
|
153
137
|
with pytest.raises(typer.Exit) as exc_info:
|
|
154
138
|
push_environment(str(tmp_path))
|
|
155
|
-
|
|
139
|
+
|
|
156
140
|
assert exc_info.value.exit_code == 1
|
|
157
141
|
|
|
158
142
|
@mock.patch("requests.post")
|
|
@@ -162,8 +146,14 @@ class TestPushEnvironment:
|
|
|
162
146
|
@mock.patch("hud.cli.push.settings")
|
|
163
147
|
@mock.patch("hud.cli.push.HUDDesign")
|
|
164
148
|
def test_push_auto_detect_username(
|
|
165
|
-
self,
|
|
166
|
-
|
|
149
|
+
self,
|
|
150
|
+
mock_design_class,
|
|
151
|
+
mock_settings,
|
|
152
|
+
mock_get_username,
|
|
153
|
+
mock_run,
|
|
154
|
+
mock_popen,
|
|
155
|
+
mock_post,
|
|
156
|
+
tmp_path,
|
|
167
157
|
):
|
|
168
158
|
"""Test auto-detecting Docker username and pushing."""
|
|
169
159
|
# Setup mocks
|
|
@@ -172,15 +162,12 @@ class TestPushEnvironment:
|
|
|
172
162
|
mock_settings.api_key = "test-key"
|
|
173
163
|
mock_settings.hud_telemetry_url = "https://api.hud.test"
|
|
174
164
|
mock_get_username.return_value = "testuser"
|
|
175
|
-
|
|
165
|
+
|
|
176
166
|
# Create lock file
|
|
177
|
-
lock_data = {
|
|
178
|
-
"image": "original/image:v1.0",
|
|
179
|
-
"build": {"version": "0.1.0"}
|
|
180
|
-
}
|
|
167
|
+
lock_data = {"image": "original/image:v1.0", "build": {"version": "0.1.0"}}
|
|
181
168
|
lock_file = tmp_path / "hud.lock.yaml"
|
|
182
169
|
lock_file.write_text(yaml.dump(lock_data))
|
|
183
|
-
|
|
170
|
+
|
|
184
171
|
# Mock docker commands
|
|
185
172
|
def mock_run_impl(*args, **kwargs):
|
|
186
173
|
cmd = args[0]
|
|
@@ -192,50 +179,48 @@ class TestPushEnvironment:
|
|
|
192
179
|
elif cmd[1] == "tag":
|
|
193
180
|
return mock.Mock(returncode=0)
|
|
194
181
|
return mock.Mock(returncode=0)
|
|
195
|
-
|
|
182
|
+
|
|
196
183
|
mock_run.side_effect = mock_run_impl
|
|
197
|
-
|
|
184
|
+
|
|
198
185
|
# Mock docker push
|
|
199
186
|
mock_process = mock.Mock()
|
|
200
187
|
mock_process.stdout = ["Pushing image...", "Push complete"]
|
|
201
188
|
mock_process.wait.return_value = None
|
|
202
189
|
mock_process.returncode = 0
|
|
203
190
|
mock_popen.return_value = mock_process
|
|
204
|
-
|
|
191
|
+
|
|
205
192
|
# Mock registry upload
|
|
206
193
|
mock_post.return_value = mock.Mock(status_code=201)
|
|
207
|
-
|
|
194
|
+
|
|
208
195
|
# Run push
|
|
209
196
|
push_environment(str(tmp_path), yes=True)
|
|
210
|
-
|
|
197
|
+
|
|
211
198
|
# Verify docker commands
|
|
212
199
|
assert mock_run.call_count >= 2
|
|
213
200
|
mock_popen.assert_called_once()
|
|
214
|
-
|
|
201
|
+
|
|
215
202
|
# Verify registry upload
|
|
216
203
|
mock_post.assert_called_once()
|
|
217
204
|
call_args = mock_post.call_args
|
|
218
|
-
assert "testuser/image
|
|
205
|
+
assert "testuser/image%3A0.1.0" in call_args[0][0]
|
|
219
206
|
|
|
220
207
|
@mock.patch("subprocess.run")
|
|
221
208
|
@mock.patch("hud.cli.push.settings")
|
|
222
209
|
@mock.patch("hud.cli.push.HUDDesign")
|
|
223
|
-
def test_push_explicit_image(
|
|
224
|
-
self, mock_design_class, mock_settings, mock_run, tmp_path
|
|
225
|
-
):
|
|
210
|
+
def test_push_explicit_image(self, mock_design_class, mock_settings, mock_run, tmp_path):
|
|
226
211
|
"""Test pushing with explicit image name."""
|
|
227
212
|
mock_design = mock.Mock()
|
|
228
213
|
mock_design_class.return_value = mock_design
|
|
229
214
|
mock_settings.api_key = "test-key"
|
|
230
|
-
|
|
215
|
+
|
|
231
216
|
# Create lock file
|
|
232
217
|
lock_data = {"image": "local:latest"}
|
|
233
218
|
lock_file = tmp_path / "hud.lock.yaml"
|
|
234
219
|
lock_file.write_text(yaml.dump(lock_data))
|
|
235
|
-
|
|
220
|
+
|
|
236
221
|
# Mock docker inspect for non-existent local image
|
|
237
222
|
mock_run.side_effect = subprocess.CalledProcessError(1, "docker")
|
|
238
|
-
|
|
223
|
+
|
|
239
224
|
with pytest.raises(typer.Exit):
|
|
240
225
|
push_environment(str(tmp_path), image="myrepo/myimage:v2")
|
|
241
226
|
|
|
@@ -243,19 +228,17 @@ class TestPushEnvironment:
|
|
|
243
228
|
@mock.patch("subprocess.run")
|
|
244
229
|
@mock.patch("hud.cli.push.settings")
|
|
245
230
|
@mock.patch("hud.cli.push.HUDDesign")
|
|
246
|
-
def test_push_with_tag(
|
|
247
|
-
self, mock_design_class, mock_settings, mock_run, mock_popen, tmp_path
|
|
248
|
-
):
|
|
231
|
+
def test_push_with_tag(self, mock_design_class, mock_settings, mock_run, mock_popen, tmp_path):
|
|
249
232
|
"""Test pushing with explicit tag."""
|
|
250
233
|
mock_design = mock.Mock()
|
|
251
234
|
mock_design_class.return_value = mock_design
|
|
252
235
|
mock_settings.api_key = "test-key"
|
|
253
|
-
|
|
236
|
+
|
|
254
237
|
# Create lock file
|
|
255
238
|
lock_data = {"image": "test:latest"}
|
|
256
239
|
lock_file = tmp_path / "hud.lock.yaml"
|
|
257
240
|
lock_file.write_text(yaml.dump(lock_data))
|
|
258
|
-
|
|
241
|
+
|
|
259
242
|
# Mock docker commands
|
|
260
243
|
def mock_run_impl(*args, **kwargs):
|
|
261
244
|
cmd = args[0]
|
|
@@ -267,19 +250,19 @@ class TestPushEnvironment:
|
|
|
267
250
|
elif cmd[1] == "tag":
|
|
268
251
|
return mock.Mock(returncode=0)
|
|
269
252
|
return mock.Mock(returncode=0)
|
|
270
|
-
|
|
253
|
+
|
|
271
254
|
mock_run.side_effect = mock_run_impl
|
|
272
|
-
|
|
255
|
+
|
|
273
256
|
# Mock docker push
|
|
274
257
|
mock_process = mock.Mock()
|
|
275
258
|
mock_process.stdout = []
|
|
276
259
|
mock_process.wait.return_value = None
|
|
277
260
|
mock_process.returncode = 0
|
|
278
261
|
mock_popen.return_value = mock_process
|
|
279
|
-
|
|
262
|
+
|
|
280
263
|
# Run push
|
|
281
264
|
push_environment(str(tmp_path), image="user/test", tag="v2.0", yes=True)
|
|
282
|
-
|
|
265
|
+
|
|
283
266
|
# Verify tag was used
|
|
284
267
|
tag_call = [c for c in mock_run.call_args_list if c[0][0][1] == "tag"]
|
|
285
268
|
assert len(tag_call) > 0
|
|
@@ -291,19 +274,21 @@ class TestPushEnvironment:
|
|
|
291
274
|
"""Test handling Docker push failure."""
|
|
292
275
|
mock_design = mock.Mock()
|
|
293
276
|
mock_design_class.return_value = mock_design
|
|
294
|
-
|
|
277
|
+
|
|
295
278
|
# Mock docker push failure
|
|
296
279
|
mock_process = mock.Mock()
|
|
297
280
|
mock_process.stdout = ["Error: access denied"]
|
|
298
281
|
mock_process.wait.return_value = None
|
|
299
282
|
mock_process.returncode = 1
|
|
300
283
|
mock_popen.return_value = mock_process
|
|
301
|
-
|
|
284
|
+
|
|
302
285
|
with mock.patch("hud.cli.push.settings") as mock_settings:
|
|
303
286
|
mock_settings.api_key = "test-key"
|
|
304
|
-
with
|
|
305
|
-
|
|
306
|
-
|
|
287
|
+
with (
|
|
288
|
+
mock.patch("subprocess.run"),
|
|
289
|
+
pytest.raises(typer.Exit),
|
|
290
|
+
):
|
|
291
|
+
push_environment(".", image="test:latest", yes=True)
|
|
307
292
|
|
|
308
293
|
@mock.patch("hud.cli.push.get_docker_image_labels")
|
|
309
294
|
@mock.patch("subprocess.run")
|
|
@@ -316,18 +301,18 @@ class TestPushEnvironment:
|
|
|
316
301
|
mock_design = mock.Mock()
|
|
317
302
|
mock_design_class.return_value = mock_design
|
|
318
303
|
mock_settings.api_key = "test-key"
|
|
319
|
-
|
|
304
|
+
|
|
320
305
|
# Create lock file
|
|
321
306
|
lock_data = {"image": "test:latest"}
|
|
322
307
|
lock_file = tmp_path / "hud.lock.yaml"
|
|
323
308
|
lock_file.write_text(yaml.dump(lock_data))
|
|
324
|
-
|
|
309
|
+
|
|
325
310
|
# Mock labels
|
|
326
311
|
mock_get_labels.return_value = {
|
|
327
312
|
"org.hud.manifest.head": "abc123def456",
|
|
328
|
-
"org.hud.version": "1.2.3"
|
|
313
|
+
"org.hud.version": "1.2.3",
|
|
329
314
|
}
|
|
330
|
-
|
|
315
|
+
|
|
331
316
|
# Mock docker commands - first inspect succeeds to get to label check
|
|
332
317
|
# Provide explicit image to bypass username check
|
|
333
318
|
def mock_run_impl(*args, **kwargs):
|
|
@@ -339,13 +324,13 @@ class TestPushEnvironment:
|
|
|
339
324
|
# Fail on tag to exit after labels are checked
|
|
340
325
|
raise subprocess.CalledProcessError(1, cmd)
|
|
341
326
|
return mock.Mock(returncode=0)
|
|
342
|
-
|
|
327
|
+
|
|
343
328
|
mock_run.side_effect = mock_run_impl
|
|
344
|
-
|
|
329
|
+
|
|
345
330
|
# Provide explicit image to ensure we reach label check
|
|
346
331
|
with pytest.raises(subprocess.CalledProcessError):
|
|
347
332
|
push_environment(str(tmp_path), image="test:v2", verbose=True)
|
|
348
|
-
|
|
333
|
+
|
|
349
334
|
# Verify labels were checked
|
|
350
335
|
mock_get_labels.assert_called_once_with("test:latest")
|
|
351
336
|
|
|
@@ -357,10 +342,8 @@ class TestPushCommand:
|
|
|
357
342
|
"""Test basic push command."""
|
|
358
343
|
with mock.patch("hud.cli.push.push_environment") as mock_push:
|
|
359
344
|
push_command()
|
|
360
|
-
|
|
361
|
-
mock_push.assert_called_once_with(
|
|
362
|
-
".", None, None, False, False, False
|
|
363
|
-
)
|
|
345
|
+
|
|
346
|
+
mock_push.assert_called_once_with(".", None, None, False, False, False)
|
|
364
347
|
|
|
365
348
|
def test_push_command_with_options(self):
|
|
366
349
|
"""Test push command with all options."""
|
|
@@ -371,9 +354,7 @@ class TestPushCommand:
|
|
|
371
354
|
tag="v1.0",
|
|
372
355
|
sign=True,
|
|
373
356
|
yes=True,
|
|
374
|
-
verbose=True
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
mock_push.assert_called_once_with(
|
|
378
|
-
"./myenv", "myrepo/myimage", "v1.0", True, True, True
|
|
357
|
+
verbose=True,
|
|
379
358
|
)
|
|
359
|
+
|
|
360
|
+
mock_push.assert_called_once_with("./myenv", "myrepo/myimage", "v1.0", True, True, True)
|
hud/cli/tests/test_registry.py
CHANGED
|
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from unittest import mock
|
|
7
7
|
|
|
8
|
-
import pytest
|
|
9
8
|
import yaml
|
|
10
9
|
|
|
11
|
-
from hud.cli.registry import (
|
|
10
|
+
from hud.cli.utils.registry import (
|
|
12
11
|
extract_digest_from_image,
|
|
13
12
|
extract_name_and_tag,
|
|
14
13
|
get_registry_dir,
|
|
@@ -25,7 +24,7 @@ class TestGetRegistryDir:
|
|
|
25
24
|
"""Test default registry directory."""
|
|
26
25
|
with mock.patch("pathlib.Path.home") as mock_home:
|
|
27
26
|
mock_home.return_value = Path("/home/user")
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
registry_dir = get_registry_dir()
|
|
30
29
|
assert registry_dir == Path("/home/user/.hud/envs")
|
|
31
30
|
|
|
@@ -119,65 +118,64 @@ class TestExtractNameAndTag:
|
|
|
119
118
|
class TestSaveToRegistry:
|
|
120
119
|
"""Test saving to local registry."""
|
|
121
120
|
|
|
122
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
121
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
123
122
|
def test_save_success(self, mock_design_class, tmp_path):
|
|
124
123
|
"""Test successful save to registry."""
|
|
125
124
|
mock_design = mock.Mock()
|
|
126
125
|
mock_design_class.return_value = mock_design
|
|
127
|
-
|
|
126
|
+
|
|
128
127
|
# Mock home directory
|
|
129
128
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
130
|
-
lock_data = {
|
|
131
|
-
|
|
132
|
-
"tools": ["tool1", "tool2"]
|
|
133
|
-
}
|
|
134
|
-
|
|
129
|
+
lock_data = {"image": "test:latest@sha256:abc123", "tools": ["tool1", "tool2"]}
|
|
130
|
+
|
|
135
131
|
result = save_to_registry(lock_data, "test:latest@sha256:abc123def456789")
|
|
136
|
-
|
|
132
|
+
|
|
137
133
|
assert result is not None
|
|
138
134
|
assert result.exists()
|
|
139
135
|
assert result.name == "hud.lock.yaml"
|
|
140
|
-
|
|
136
|
+
|
|
141
137
|
# Verify content
|
|
142
138
|
with open(result) as f:
|
|
143
139
|
saved_data = yaml.safe_load(f)
|
|
144
140
|
assert saved_data == lock_data
|
|
145
|
-
|
|
141
|
+
|
|
146
142
|
# Verify directory structure
|
|
147
143
|
assert result.parent.name == "abc123def456"
|
|
148
|
-
|
|
144
|
+
|
|
149
145
|
mock_design.success.assert_called_once()
|
|
150
146
|
|
|
151
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
147
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
152
148
|
def test_save_verbose(self, mock_design_class, tmp_path):
|
|
153
149
|
"""Test save with verbose output."""
|
|
154
150
|
mock_design = mock.Mock()
|
|
155
151
|
mock_design_class.return_value = mock_design
|
|
156
|
-
|
|
152
|
+
|
|
157
153
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
158
154
|
lock_data = {"image": "test:v1"}
|
|
159
|
-
|
|
155
|
+
|
|
160
156
|
result = save_to_registry(lock_data, "test:v1", verbose=True)
|
|
161
|
-
|
|
157
|
+
|
|
162
158
|
assert result is not None
|
|
163
159
|
# Should show verbose info
|
|
164
160
|
assert mock_design.info.call_count >= 1
|
|
165
161
|
|
|
166
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
162
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
167
163
|
def test_save_failure(self, mock_design_class):
|
|
168
164
|
"""Test handling save failure."""
|
|
169
165
|
mock_design = mock.Mock()
|
|
170
166
|
mock_design_class.return_value = mock_design
|
|
171
|
-
|
|
167
|
+
|
|
172
168
|
# Mock file operations to fail
|
|
173
|
-
with
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
169
|
+
with (
|
|
170
|
+
mock.patch("builtins.open", side_effect=OSError("Permission denied")),
|
|
171
|
+
mock.patch("pathlib.Path.home", return_value=Path("/tmp")),
|
|
172
|
+
):
|
|
173
|
+
lock_data = {"image": "test:latest"}
|
|
174
|
+
|
|
175
|
+
result = save_to_registry(lock_data, "test:latest", verbose=True)
|
|
176
|
+
|
|
177
|
+
assert result is None
|
|
178
|
+
mock_design.warning.assert_called_once()
|
|
181
179
|
|
|
182
180
|
|
|
183
181
|
class TestLoadFromRegistry:
|
|
@@ -190,11 +188,11 @@ class TestLoadFromRegistry:
|
|
|
190
188
|
registry_dir = get_registry_dir()
|
|
191
189
|
digest_dir = registry_dir / "abc123"
|
|
192
190
|
digest_dir.mkdir(parents=True)
|
|
193
|
-
|
|
191
|
+
|
|
194
192
|
lock_data = {"image": "test:latest", "version": "1.0"}
|
|
195
193
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
196
194
|
lock_file.write_text(yaml.dump(lock_data))
|
|
197
|
-
|
|
195
|
+
|
|
198
196
|
# Load it back
|
|
199
197
|
loaded = load_from_registry("abc123")
|
|
200
198
|
assert loaded == lock_data
|
|
@@ -211,10 +209,10 @@ class TestLoadFromRegistry:
|
|
|
211
209
|
registry_dir = get_registry_dir()
|
|
212
210
|
digest_dir = registry_dir / "bad"
|
|
213
211
|
digest_dir.mkdir(parents=True)
|
|
214
|
-
|
|
212
|
+
|
|
215
213
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
216
214
|
lock_file.write_text("invalid: yaml: content:")
|
|
217
|
-
|
|
215
|
+
|
|
218
216
|
loaded = load_from_registry("bad")
|
|
219
217
|
assert loaded is None
|
|
220
218
|
|
|
@@ -232,26 +230,26 @@ class TestListRegistryEntries:
|
|
|
232
230
|
"""Test listing multiple entries."""
|
|
233
231
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
234
232
|
registry_dir = get_registry_dir()
|
|
235
|
-
|
|
233
|
+
|
|
236
234
|
# Create several entries
|
|
237
235
|
for digest in ["abc123", "def456", "ghi789"]:
|
|
238
236
|
digest_dir = registry_dir / digest
|
|
239
237
|
digest_dir.mkdir(parents=True)
|
|
240
238
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
241
239
|
lock_file.write_text(f"image: test:{digest}")
|
|
242
|
-
|
|
240
|
+
|
|
243
241
|
# Create a directory without lock file (should be ignored)
|
|
244
242
|
(registry_dir / "nolockfile").mkdir(parents=True)
|
|
245
|
-
|
|
243
|
+
|
|
246
244
|
# Create a file in registry dir (should be ignored)
|
|
247
245
|
(registry_dir / "README.txt").write_text("info")
|
|
248
|
-
|
|
246
|
+
|
|
249
247
|
entries = list_registry_entries()
|
|
250
|
-
|
|
248
|
+
|
|
251
249
|
assert len(entries) == 3
|
|
252
250
|
digests = [entry[0] for entry in entries]
|
|
253
251
|
assert set(digests) == {"abc123", "def456", "ghi789"}
|
|
254
|
-
|
|
252
|
+
|
|
255
253
|
# Verify all paths are lock files
|
|
256
254
|
for _, lock_path in entries:
|
|
257
255
|
assert lock_path.name == "hud.lock.yaml"
|
hud/cli/tests/test_utils.py
CHANGED
|
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from hud.cli.utils import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
|
|
10
|
+
from hud.cli.utils.logging import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class TestColors:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for HUD CLI."""
|
|
@@ -81,3 +81,39 @@ def image_exists(image_name: str) -> bool:
|
|
|
81
81
|
stderr=subprocess.DEVNULL,
|
|
82
82
|
)
|
|
83
83
|
return result.returncode == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def remove_container(container_name: str) -> bool:
|
|
87
|
+
"""Remove a Docker container by name.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
container_name: Name of the container to remove
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if successful or container doesn't exist, False on error
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run( # noqa: S603
|
|
97
|
+
["docker", "rm", "-f", container_name], # noqa: S607
|
|
98
|
+
stdout=subprocess.DEVNULL,
|
|
99
|
+
stderr=subprocess.DEVNULL,
|
|
100
|
+
check=False, # Don't raise error if container doesn't exist
|
|
101
|
+
)
|
|
102
|
+
return True
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_container_name(identifier: str, prefix: str = "hud") -> str:
|
|
108
|
+
"""Generate a safe container name from an identifier.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
identifier: Image name or other identifier
|
|
112
|
+
prefix: Prefix for the container name
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Safe container name with special characters replaced
|
|
116
|
+
"""
|
|
117
|
+
# Replace special characters with hyphens
|
|
118
|
+
safe_name = identifier.replace(":", "-").replace("/", "-").replace("\\", "-")
|
|
119
|
+
return f"{prefix}-{safe_name}"
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
7
|
|
|
9
8
|
import toml
|
|
10
9
|
|
|
@@ -32,7 +31,7 @@ def get_image_name(directory: str | Path, image_override: str | None = None) ->
|
|
|
32
31
|
if config.get("tool", {}).get("hud", {}).get("image"):
|
|
33
32
|
return config["tool"]["hud"]["image"], "cache"
|
|
34
33
|
except Exception:
|
|
35
|
-
|
|
34
|
+
design.error("Error loading pyproject.toml")
|
|
36
35
|
|
|
37
36
|
# Auto-generate with :dev tag
|
|
38
37
|
dir_path = Path(directory).resolve() # Get absolute path first
|
|
@@ -74,7 +73,7 @@ def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool =
|
|
|
74
73
|
|
|
75
74
|
def build_environment(directory: str | Path, image_name: str, no_cache: bool = False) -> bool:
|
|
76
75
|
"""Build Docker image for an environment.
|
|
77
|
-
|
|
76
|
+
|
|
78
77
|
Returns:
|
|
79
78
|
True if build succeeded, False otherwise
|
|
80
79
|
"""
|
|
@@ -112,7 +111,7 @@ def image_exists(image_name: str) -> bool:
|
|
|
112
111
|
|
|
113
112
|
def is_environment_directory(path: str | Path) -> bool:
|
|
114
113
|
"""Check if a path looks like an environment directory.
|
|
115
|
-
|
|
114
|
+
|
|
116
115
|
An environment directory should have:
|
|
117
116
|
- A Dockerfile
|
|
118
117
|
- A pyproject.toml file
|
|
@@ -121,13 +120,14 @@ def is_environment_directory(path: str | Path) -> bool:
|
|
|
121
120
|
dir_path = Path(path)
|
|
122
121
|
if not dir_path.is_dir():
|
|
123
122
|
return False
|
|
124
|
-
|
|
123
|
+
|
|
125
124
|
# Must have Dockerfile
|
|
126
125
|
if not (dir_path / "Dockerfile").exists():
|
|
127
126
|
return False
|
|
128
|
-
|
|
127
|
+
|
|
129
128
|
# Must have pyproject.toml
|
|
130
129
|
if not (dir_path / "pyproject.toml").exists():
|
|
130
|
+
design.error("pyproject.toml not found")
|
|
131
131
|
return False
|
|
132
|
-
|
|
132
|
+
|
|
133
133
|
return True
|