hud-python 0.4.7__py3-none-any.whl → 0.4.9__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/agents/base.py +50 -1
- hud/cli/__init__.py +120 -1
- hud/cli/analyze_metadata.py +29 -41
- hud/cli/build.py +7 -0
- hud/cli/debug.py +8 -1
- hud/cli/eval.py +226 -0
- hud/cli/list_func.py +212 -0
- hud/cli/pull.py +4 -13
- hud/cli/push.py +84 -41
- hud/cli/registry.py +155 -0
- hud/cli/remove.py +200 -0
- hud/cli/tests/test_analyze_metadata.py +277 -0
- hud/cli/tests/test_build.py +450 -0
- hud/cli/tests/test_list_func.py +288 -0
- hud/cli/tests/test_pull.py +400 -0
- hud/cli/tests/test_push.py +379 -0
- hud/cli/tests/test_registry.py +264 -0
- hud/clients/base.py +13 -1
- hud/clients/fastmcp.py +13 -9
- hud/clients/mcp_use.py +1 -1
- hud/tools/__init__.py +2 -0
- hud/tools/response.py +54 -0
- hud/utils/design.py +10 -0
- hud/utils/mcp.py +14 -2
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.7.dist-info → hud_python-0.4.9.dist-info}/METADATA +13 -1
- {hud_python-0.4.7.dist-info → hud_python-0.4.9.dist-info}/RECORD +31 -20
- {hud_python-0.4.7.dist-info → hud_python-0.4.9.dist-info}/WHEEL +0 -0
- {hud_python-0.4.7.dist-info → hud_python-0.4.9.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.7.dist-info → hud_python-0.4.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Tests for push.py - Push HUD environments to registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import requests
|
|
14
|
+
import typer
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from hud.cli.push import (
|
|
18
|
+
get_docker_image_labels,
|
|
19
|
+
get_docker_username,
|
|
20
|
+
push_command,
|
|
21
|
+
push_environment,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestGetDockerUsername:
|
|
26
|
+
"""Test getting Docker username."""
|
|
27
|
+
|
|
28
|
+
def test_get_username_from_config(self, tmp_path):
|
|
29
|
+
"""Test getting username from Docker config."""
|
|
30
|
+
# Create mock Docker config
|
|
31
|
+
docker_dir = tmp_path / ".docker"
|
|
32
|
+
docker_dir.mkdir()
|
|
33
|
+
|
|
34
|
+
config_file = docker_dir / "config.json"
|
|
35
|
+
config = {
|
|
36
|
+
"auths": {
|
|
37
|
+
"https://index.docker.io/v1/": {
|
|
38
|
+
"auth": base64.b64encode(b"testuser:testpass").decode()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
config_file.write_text(json.dumps(config))
|
|
43
|
+
|
|
44
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
45
|
+
username = get_docker_username()
|
|
46
|
+
|
|
47
|
+
assert username == "testuser"
|
|
48
|
+
|
|
49
|
+
def test_get_username_no_config(self, tmp_path):
|
|
50
|
+
"""Test when no Docker config exists."""
|
|
51
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
52
|
+
username = get_docker_username()
|
|
53
|
+
|
|
54
|
+
assert username is None
|
|
55
|
+
|
|
56
|
+
def test_get_username_token_auth(self, tmp_path):
|
|
57
|
+
"""Test skipping token-based auth."""
|
|
58
|
+
docker_dir = tmp_path / ".docker"
|
|
59
|
+
docker_dir.mkdir()
|
|
60
|
+
|
|
61
|
+
config_file = docker_dir / "config.json"
|
|
62
|
+
config = {
|
|
63
|
+
"auths": {
|
|
64
|
+
"docker.io": {
|
|
65
|
+
"auth": base64.b64encode(b"token:xyz").decode()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
config_file.write_text(json.dumps(config))
|
|
70
|
+
|
|
71
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
72
|
+
username = get_docker_username()
|
|
73
|
+
|
|
74
|
+
assert username is None
|
|
75
|
+
|
|
76
|
+
@mock.patch("subprocess.run")
|
|
77
|
+
def test_get_username_credential_helper(self, mock_run, tmp_path):
|
|
78
|
+
"""Test getting username from credential helper."""
|
|
79
|
+
docker_dir = tmp_path / ".docker"
|
|
80
|
+
docker_dir.mkdir()
|
|
81
|
+
|
|
82
|
+
config_file = docker_dir / "config.json"
|
|
83
|
+
config = {"credsStore": "desktop"}
|
|
84
|
+
config_file.write_text(json.dumps(config))
|
|
85
|
+
|
|
86
|
+
# Mock credential helper calls
|
|
87
|
+
mock_run.side_effect = [
|
|
88
|
+
mock.Mock(returncode=0, stdout='{"https://index.docker.io/v1/": "creds"}'),
|
|
89
|
+
mock.Mock(returncode=0, stdout='{"Username": "helperuser", "Secret": "pass"}')
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
93
|
+
username = get_docker_username()
|
|
94
|
+
|
|
95
|
+
assert username == "helperuser"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestGetDockerImageLabels:
|
|
99
|
+
"""Test getting Docker image labels."""
|
|
100
|
+
|
|
101
|
+
@mock.patch("subprocess.run")
|
|
102
|
+
def test_get_labels_success(self, mock_run):
|
|
103
|
+
"""Test successfully getting image labels."""
|
|
104
|
+
labels = {
|
|
105
|
+
"org.hud.manifest.head": "abc123",
|
|
106
|
+
"org.hud.version": "1.0.0"
|
|
107
|
+
}
|
|
108
|
+
mock_run.return_value = mock.Mock(
|
|
109
|
+
returncode=0,
|
|
110
|
+
stdout=json.dumps(labels),
|
|
111
|
+
stderr=""
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
result = get_docker_image_labels("test:latest")
|
|
115
|
+
assert result == labels
|
|
116
|
+
|
|
117
|
+
@mock.patch("subprocess.run")
|
|
118
|
+
def test_get_labels_failure(self, mock_run):
|
|
119
|
+
"""Test handling failure to get labels."""
|
|
120
|
+
mock_run.side_effect = Exception("Command failed")
|
|
121
|
+
|
|
122
|
+
result = get_docker_image_labels("test:latest")
|
|
123
|
+
assert result == {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestPushEnvironment:
|
|
127
|
+
"""Test the main push_environment function."""
|
|
128
|
+
|
|
129
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
130
|
+
def test_push_no_lock_file(self, mock_design_class, tmp_path):
|
|
131
|
+
"""Test pushing when no lock file exists."""
|
|
132
|
+
mock_design = mock.Mock()
|
|
133
|
+
mock_design_class.return_value = mock_design
|
|
134
|
+
|
|
135
|
+
with pytest.raises(typer.Exit) as exc_info:
|
|
136
|
+
push_environment(str(tmp_path))
|
|
137
|
+
|
|
138
|
+
assert exc_info.value.exit_code == 1
|
|
139
|
+
mock_design.error.assert_called()
|
|
140
|
+
|
|
141
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
142
|
+
@mock.patch("hud.cli.push.settings")
|
|
143
|
+
def test_push_no_api_key(self, mock_settings, mock_design_class, tmp_path):
|
|
144
|
+
"""Test pushing without API key."""
|
|
145
|
+
mock_design = mock.Mock()
|
|
146
|
+
mock_design_class.return_value = mock_design
|
|
147
|
+
mock_settings.api_key = None
|
|
148
|
+
|
|
149
|
+
# Create lock file
|
|
150
|
+
lock_file = tmp_path / "hud.lock.yaml"
|
|
151
|
+
lock_file.write_text(yaml.dump({"image": "test:latest"}))
|
|
152
|
+
|
|
153
|
+
with pytest.raises(typer.Exit) as exc_info:
|
|
154
|
+
push_environment(str(tmp_path))
|
|
155
|
+
|
|
156
|
+
assert exc_info.value.exit_code == 1
|
|
157
|
+
|
|
158
|
+
@mock.patch("requests.post")
|
|
159
|
+
@mock.patch("subprocess.Popen")
|
|
160
|
+
@mock.patch("subprocess.run")
|
|
161
|
+
@mock.patch("hud.cli.push.get_docker_username")
|
|
162
|
+
@mock.patch("hud.cli.push.settings")
|
|
163
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
164
|
+
def test_push_auto_detect_username(
|
|
165
|
+
self, mock_design_class, mock_settings, mock_get_username,
|
|
166
|
+
mock_run, mock_popen, mock_post, tmp_path
|
|
167
|
+
):
|
|
168
|
+
"""Test auto-detecting Docker username and pushing."""
|
|
169
|
+
# Setup mocks
|
|
170
|
+
mock_design = mock.Mock()
|
|
171
|
+
mock_design_class.return_value = mock_design
|
|
172
|
+
mock_settings.api_key = "test-key"
|
|
173
|
+
mock_settings.hud_telemetry_url = "https://api.hud.test"
|
|
174
|
+
mock_get_username.return_value = "testuser"
|
|
175
|
+
|
|
176
|
+
# Create lock file
|
|
177
|
+
lock_data = {
|
|
178
|
+
"image": "original/image:v1.0",
|
|
179
|
+
"build": {"version": "0.1.0"}
|
|
180
|
+
}
|
|
181
|
+
lock_file = tmp_path / "hud.lock.yaml"
|
|
182
|
+
lock_file.write_text(yaml.dump(lock_data))
|
|
183
|
+
|
|
184
|
+
# Mock docker commands
|
|
185
|
+
def mock_run_impl(*args, **kwargs):
|
|
186
|
+
cmd = args[0]
|
|
187
|
+
if cmd[1] == "inspect":
|
|
188
|
+
if len(cmd) == 3: # docker inspect <image>
|
|
189
|
+
return mock.Mock(returncode=0, stdout="")
|
|
190
|
+
else: # docker inspect --format ... <image>
|
|
191
|
+
return mock.Mock(returncode=0, stdout="testuser/image:0.1.0@sha256:abc123")
|
|
192
|
+
elif cmd[1] == "tag":
|
|
193
|
+
return mock.Mock(returncode=0)
|
|
194
|
+
return mock.Mock(returncode=0)
|
|
195
|
+
|
|
196
|
+
mock_run.side_effect = mock_run_impl
|
|
197
|
+
|
|
198
|
+
# Mock docker push
|
|
199
|
+
mock_process = mock.Mock()
|
|
200
|
+
mock_process.stdout = ["Pushing image...", "Push complete"]
|
|
201
|
+
mock_process.wait.return_value = None
|
|
202
|
+
mock_process.returncode = 0
|
|
203
|
+
mock_popen.return_value = mock_process
|
|
204
|
+
|
|
205
|
+
# Mock registry upload
|
|
206
|
+
mock_post.return_value = mock.Mock(status_code=201)
|
|
207
|
+
|
|
208
|
+
# Run push
|
|
209
|
+
push_environment(str(tmp_path), yes=True)
|
|
210
|
+
|
|
211
|
+
# Verify docker commands
|
|
212
|
+
assert mock_run.call_count >= 2
|
|
213
|
+
mock_popen.assert_called_once()
|
|
214
|
+
|
|
215
|
+
# Verify registry upload
|
|
216
|
+
mock_post.assert_called_once()
|
|
217
|
+
call_args = mock_post.call_args
|
|
218
|
+
assert "testuser/image:0.1.0" in call_args[0][0]
|
|
219
|
+
|
|
220
|
+
@mock.patch("subprocess.run")
|
|
221
|
+
@mock.patch("hud.cli.push.settings")
|
|
222
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
223
|
+
def test_push_explicit_image(
|
|
224
|
+
self, mock_design_class, mock_settings, mock_run, tmp_path
|
|
225
|
+
):
|
|
226
|
+
"""Test pushing with explicit image name."""
|
|
227
|
+
mock_design = mock.Mock()
|
|
228
|
+
mock_design_class.return_value = mock_design
|
|
229
|
+
mock_settings.api_key = "test-key"
|
|
230
|
+
|
|
231
|
+
# Create lock file
|
|
232
|
+
lock_data = {"image": "local:latest"}
|
|
233
|
+
lock_file = tmp_path / "hud.lock.yaml"
|
|
234
|
+
lock_file.write_text(yaml.dump(lock_data))
|
|
235
|
+
|
|
236
|
+
# Mock docker inspect for non-existent local image
|
|
237
|
+
mock_run.side_effect = subprocess.CalledProcessError(1, "docker")
|
|
238
|
+
|
|
239
|
+
with pytest.raises(typer.Exit):
|
|
240
|
+
push_environment(str(tmp_path), image="myrepo/myimage:v2")
|
|
241
|
+
|
|
242
|
+
@mock.patch("subprocess.Popen")
|
|
243
|
+
@mock.patch("subprocess.run")
|
|
244
|
+
@mock.patch("hud.cli.push.settings")
|
|
245
|
+
@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
|
+
):
|
|
249
|
+
"""Test pushing with explicit tag."""
|
|
250
|
+
mock_design = mock.Mock()
|
|
251
|
+
mock_design_class.return_value = mock_design
|
|
252
|
+
mock_settings.api_key = "test-key"
|
|
253
|
+
|
|
254
|
+
# Create lock file
|
|
255
|
+
lock_data = {"image": "test:latest"}
|
|
256
|
+
lock_file = tmp_path / "hud.lock.yaml"
|
|
257
|
+
lock_file.write_text(yaml.dump(lock_data))
|
|
258
|
+
|
|
259
|
+
# Mock docker commands
|
|
260
|
+
def mock_run_impl(*args, **kwargs):
|
|
261
|
+
cmd = args[0]
|
|
262
|
+
if cmd[1] == "inspect":
|
|
263
|
+
if len(cmd) == 3: # docker inspect <image>
|
|
264
|
+
return mock.Mock(returncode=0)
|
|
265
|
+
else: # docker inspect --format ... <image>
|
|
266
|
+
return mock.Mock(returncode=0, stdout="user/test:v2.0")
|
|
267
|
+
elif cmd[1] == "tag":
|
|
268
|
+
return mock.Mock(returncode=0)
|
|
269
|
+
return mock.Mock(returncode=0)
|
|
270
|
+
|
|
271
|
+
mock_run.side_effect = mock_run_impl
|
|
272
|
+
|
|
273
|
+
# Mock docker push
|
|
274
|
+
mock_process = mock.Mock()
|
|
275
|
+
mock_process.stdout = []
|
|
276
|
+
mock_process.wait.return_value = None
|
|
277
|
+
mock_process.returncode = 0
|
|
278
|
+
mock_popen.return_value = mock_process
|
|
279
|
+
|
|
280
|
+
# Run push
|
|
281
|
+
push_environment(str(tmp_path), image="user/test", tag="v2.0", yes=True)
|
|
282
|
+
|
|
283
|
+
# Verify tag was used
|
|
284
|
+
tag_call = [c for c in mock_run.call_args_list if c[0][0][1] == "tag"]
|
|
285
|
+
assert len(tag_call) > 0
|
|
286
|
+
assert "user/test:v2.0" in tag_call[0][0][0]
|
|
287
|
+
|
|
288
|
+
@mock.patch("subprocess.Popen")
|
|
289
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
290
|
+
def test_push_docker_failure(self, mock_design_class, mock_popen):
|
|
291
|
+
"""Test handling Docker push failure."""
|
|
292
|
+
mock_design = mock.Mock()
|
|
293
|
+
mock_design_class.return_value = mock_design
|
|
294
|
+
|
|
295
|
+
# Mock docker push failure
|
|
296
|
+
mock_process = mock.Mock()
|
|
297
|
+
mock_process.stdout = ["Error: access denied"]
|
|
298
|
+
mock_process.wait.return_value = None
|
|
299
|
+
mock_process.returncode = 1
|
|
300
|
+
mock_popen.return_value = mock_process
|
|
301
|
+
|
|
302
|
+
with mock.patch("hud.cli.push.settings") as mock_settings:
|
|
303
|
+
mock_settings.api_key = "test-key"
|
|
304
|
+
with mock.patch("subprocess.run"):
|
|
305
|
+
with pytest.raises(typer.Exit):
|
|
306
|
+
push_environment(".", image="test:latest", yes=True)
|
|
307
|
+
|
|
308
|
+
@mock.patch("hud.cli.push.get_docker_image_labels")
|
|
309
|
+
@mock.patch("subprocess.run")
|
|
310
|
+
@mock.patch("hud.cli.push.settings")
|
|
311
|
+
@mock.patch("hud.cli.push.HUDDesign")
|
|
312
|
+
def test_push_with_labels(
|
|
313
|
+
self, mock_design_class, mock_settings, mock_run, mock_get_labels, tmp_path
|
|
314
|
+
):
|
|
315
|
+
"""Test pushing with image labels."""
|
|
316
|
+
mock_design = mock.Mock()
|
|
317
|
+
mock_design_class.return_value = mock_design
|
|
318
|
+
mock_settings.api_key = "test-key"
|
|
319
|
+
|
|
320
|
+
# Create lock file
|
|
321
|
+
lock_data = {"image": "test:latest"}
|
|
322
|
+
lock_file = tmp_path / "hud.lock.yaml"
|
|
323
|
+
lock_file.write_text(yaml.dump(lock_data))
|
|
324
|
+
|
|
325
|
+
# Mock labels
|
|
326
|
+
mock_get_labels.return_value = {
|
|
327
|
+
"org.hud.manifest.head": "abc123def456",
|
|
328
|
+
"org.hud.version": "1.2.3"
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Mock docker commands - first inspect succeeds to get to label check
|
|
332
|
+
# Provide explicit image to bypass username check
|
|
333
|
+
def mock_run_impl(*args, **kwargs):
|
|
334
|
+
cmd = args[0]
|
|
335
|
+
if cmd[1] == "inspect" and len(cmd) == 3:
|
|
336
|
+
# First inspect to check if image exists
|
|
337
|
+
return mock.Mock(returncode=0)
|
|
338
|
+
elif cmd[1] == "tag":
|
|
339
|
+
# Fail on tag to exit after labels are checked
|
|
340
|
+
raise subprocess.CalledProcessError(1, cmd)
|
|
341
|
+
return mock.Mock(returncode=0)
|
|
342
|
+
|
|
343
|
+
mock_run.side_effect = mock_run_impl
|
|
344
|
+
|
|
345
|
+
# Provide explicit image to ensure we reach label check
|
|
346
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
347
|
+
push_environment(str(tmp_path), image="test:v2", verbose=True)
|
|
348
|
+
|
|
349
|
+
# Verify labels were checked
|
|
350
|
+
mock_get_labels.assert_called_once_with("test:latest")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class TestPushCommand:
|
|
354
|
+
"""Test the CLI command wrapper."""
|
|
355
|
+
|
|
356
|
+
def test_push_command_basic(self):
|
|
357
|
+
"""Test basic push command."""
|
|
358
|
+
with mock.patch("hud.cli.push.push_environment") as mock_push:
|
|
359
|
+
push_command()
|
|
360
|
+
|
|
361
|
+
mock_push.assert_called_once_with(
|
|
362
|
+
".", None, None, False, False, False
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def test_push_command_with_options(self):
|
|
366
|
+
"""Test push command with all options."""
|
|
367
|
+
with mock.patch("hud.cli.push.push_environment") as mock_push:
|
|
368
|
+
push_command(
|
|
369
|
+
directory="./myenv",
|
|
370
|
+
image="myrepo/myimage",
|
|
371
|
+
tag="v1.0",
|
|
372
|
+
sign=True,
|
|
373
|
+
yes=True,
|
|
374
|
+
verbose=True
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
mock_push.assert_called_once_with(
|
|
378
|
+
"./myenv", "myrepo/myimage", "v1.0", True, True, True
|
|
379
|
+
)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Tests for registry.py - Local registry management for HUD environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from hud.cli.registry import (
|
|
12
|
+
extract_digest_from_image,
|
|
13
|
+
extract_name_and_tag,
|
|
14
|
+
get_registry_dir,
|
|
15
|
+
list_registry_entries,
|
|
16
|
+
load_from_registry,
|
|
17
|
+
save_to_registry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestGetRegistryDir:
|
|
22
|
+
"""Test getting registry directory."""
|
|
23
|
+
|
|
24
|
+
def test_get_registry_dir(self):
|
|
25
|
+
"""Test default registry directory."""
|
|
26
|
+
with mock.patch("pathlib.Path.home") as mock_home:
|
|
27
|
+
mock_home.return_value = Path("/home/user")
|
|
28
|
+
|
|
29
|
+
registry_dir = get_registry_dir()
|
|
30
|
+
assert registry_dir == Path("/home/user/.hud/envs")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestExtractDigestFromImage:
|
|
34
|
+
"""Test extracting digest from Docker image reference."""
|
|
35
|
+
|
|
36
|
+
def test_extract_from_full_digest(self):
|
|
37
|
+
"""Test extracting from full digest reference."""
|
|
38
|
+
image = "myimage:tag@sha256:abc123def456789"
|
|
39
|
+
digest = extract_digest_from_image(image)
|
|
40
|
+
assert digest == "abc123def456"
|
|
41
|
+
|
|
42
|
+
def test_extract_from_digest_only(self):
|
|
43
|
+
"""Test extracting from digest-only format."""
|
|
44
|
+
image = "sha256:deadbeef1234567890"
|
|
45
|
+
digest = extract_digest_from_image(image)
|
|
46
|
+
assert digest == "deadbeef1234"
|
|
47
|
+
|
|
48
|
+
def test_extract_from_tag(self):
|
|
49
|
+
"""Test extracting from tagged image."""
|
|
50
|
+
image = "myimage:v1.2.3"
|
|
51
|
+
digest = extract_digest_from_image(image)
|
|
52
|
+
assert digest == "v1.2.3"
|
|
53
|
+
|
|
54
|
+
def test_extract_from_long_tag(self):
|
|
55
|
+
"""Test extracting from long tag (truncated)."""
|
|
56
|
+
image = "myimage:superlongtagname123456789"
|
|
57
|
+
digest = extract_digest_from_image(image)
|
|
58
|
+
assert digest == "superlongtag" # Max 12 chars
|
|
59
|
+
|
|
60
|
+
def test_extract_no_tag(self):
|
|
61
|
+
"""Test extracting from image without tag."""
|
|
62
|
+
image = "myimage"
|
|
63
|
+
digest = extract_digest_from_image(image)
|
|
64
|
+
assert digest == "latest"
|
|
65
|
+
|
|
66
|
+
def test_extract_with_registry(self):
|
|
67
|
+
"""Test extracting from image with registry."""
|
|
68
|
+
image = "docker.io/library/ubuntu:20.04"
|
|
69
|
+
digest = extract_digest_from_image(image)
|
|
70
|
+
assert digest == "20.04"
|
|
71
|
+
|
|
72
|
+
def test_extract_with_port(self):
|
|
73
|
+
"""Test extracting from image with port."""
|
|
74
|
+
image = "localhost:5000/myimage"
|
|
75
|
+
digest = extract_digest_from_image(image)
|
|
76
|
+
assert digest == "latest"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestExtractNameAndTag:
|
|
80
|
+
"""Test extracting name and tag from Docker image reference."""
|
|
81
|
+
|
|
82
|
+
def test_extract_simple(self):
|
|
83
|
+
"""Test extracting from simple image reference."""
|
|
84
|
+
name, tag = extract_name_and_tag("myorg/myapp:v1.0")
|
|
85
|
+
assert name == "myorg/myapp"
|
|
86
|
+
assert tag == "v1.0"
|
|
87
|
+
|
|
88
|
+
def test_extract_with_digest(self):
|
|
89
|
+
"""Test extracting from reference with digest."""
|
|
90
|
+
name, tag = extract_name_and_tag("docker.io/hudpython/test:latest@sha256:abc123")
|
|
91
|
+
assert name == "hudpython/test"
|
|
92
|
+
assert tag == "latest"
|
|
93
|
+
|
|
94
|
+
def test_extract_no_tag(self):
|
|
95
|
+
"""Test extracting from reference without tag."""
|
|
96
|
+
name, tag = extract_name_and_tag("myorg/myapp")
|
|
97
|
+
assert name == "myorg/myapp"
|
|
98
|
+
assert tag == "latest"
|
|
99
|
+
|
|
100
|
+
def test_extract_with_docker_registry(self):
|
|
101
|
+
"""Test extracting from reference with docker.io prefix."""
|
|
102
|
+
name, tag = extract_name_and_tag("docker.io/library/ubuntu:20.04")
|
|
103
|
+
assert name == "library/ubuntu"
|
|
104
|
+
assert tag == "20.04"
|
|
105
|
+
|
|
106
|
+
def test_extract_with_other_registry(self):
|
|
107
|
+
"""Test extracting from reference with custom registry."""
|
|
108
|
+
name, tag = extract_name_and_tag("gcr.io/myproject/myapp:v2")
|
|
109
|
+
assert name == "gcr.io/myproject/myapp"
|
|
110
|
+
assert tag == "v2"
|
|
111
|
+
|
|
112
|
+
def test_extract_single_name(self):
|
|
113
|
+
"""Test extracting from single name without org."""
|
|
114
|
+
name, tag = extract_name_and_tag("ubuntu")
|
|
115
|
+
assert name == "ubuntu"
|
|
116
|
+
assert tag == "latest"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestSaveToRegistry:
|
|
120
|
+
"""Test saving to local registry."""
|
|
121
|
+
|
|
122
|
+
@mock.patch("hud.cli.registry.HUDDesign")
|
|
123
|
+
def test_save_success(self, mock_design_class, tmp_path):
|
|
124
|
+
"""Test successful save to registry."""
|
|
125
|
+
mock_design = mock.Mock()
|
|
126
|
+
mock_design_class.return_value = mock_design
|
|
127
|
+
|
|
128
|
+
# Mock home directory
|
|
129
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
130
|
+
lock_data = {
|
|
131
|
+
"image": "test:latest@sha256:abc123",
|
|
132
|
+
"tools": ["tool1", "tool2"]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result = save_to_registry(lock_data, "test:latest@sha256:abc123def456789")
|
|
136
|
+
|
|
137
|
+
assert result is not None
|
|
138
|
+
assert result.exists()
|
|
139
|
+
assert result.name == "hud.lock.yaml"
|
|
140
|
+
|
|
141
|
+
# Verify content
|
|
142
|
+
with open(result) as f:
|
|
143
|
+
saved_data = yaml.safe_load(f)
|
|
144
|
+
assert saved_data == lock_data
|
|
145
|
+
|
|
146
|
+
# Verify directory structure
|
|
147
|
+
assert result.parent.name == "abc123def456"
|
|
148
|
+
|
|
149
|
+
mock_design.success.assert_called_once()
|
|
150
|
+
|
|
151
|
+
@mock.patch("hud.cli.registry.HUDDesign")
|
|
152
|
+
def test_save_verbose(self, mock_design_class, tmp_path):
|
|
153
|
+
"""Test save with verbose output."""
|
|
154
|
+
mock_design = mock.Mock()
|
|
155
|
+
mock_design_class.return_value = mock_design
|
|
156
|
+
|
|
157
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
158
|
+
lock_data = {"image": "test:v1"}
|
|
159
|
+
|
|
160
|
+
result = save_to_registry(lock_data, "test:v1", verbose=True)
|
|
161
|
+
|
|
162
|
+
assert result is not None
|
|
163
|
+
# Should show verbose info
|
|
164
|
+
assert mock_design.info.call_count >= 1
|
|
165
|
+
|
|
166
|
+
@mock.patch("hud.cli.registry.HUDDesign")
|
|
167
|
+
def test_save_failure(self, mock_design_class):
|
|
168
|
+
"""Test handling save failure."""
|
|
169
|
+
mock_design = mock.Mock()
|
|
170
|
+
mock_design_class.return_value = mock_design
|
|
171
|
+
|
|
172
|
+
# Mock file operations to fail
|
|
173
|
+
with mock.patch("builtins.open", side_effect=IOError("Permission denied")):
|
|
174
|
+
with mock.patch("pathlib.Path.home", return_value=Path("/tmp")):
|
|
175
|
+
lock_data = {"image": "test:latest"}
|
|
176
|
+
|
|
177
|
+
result = save_to_registry(lock_data, "test:latest", verbose=True)
|
|
178
|
+
|
|
179
|
+
assert result is None
|
|
180
|
+
mock_design.warning.assert_called_once()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TestLoadFromRegistry:
|
|
184
|
+
"""Test loading from local registry."""
|
|
185
|
+
|
|
186
|
+
def test_load_success(self, tmp_path):
|
|
187
|
+
"""Test successful load from registry."""
|
|
188
|
+
# Create test registry structure
|
|
189
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
190
|
+
registry_dir = get_registry_dir()
|
|
191
|
+
digest_dir = registry_dir / "abc123"
|
|
192
|
+
digest_dir.mkdir(parents=True)
|
|
193
|
+
|
|
194
|
+
lock_data = {"image": "test:latest", "version": "1.0"}
|
|
195
|
+
lock_file = digest_dir / "hud.lock.yaml"
|
|
196
|
+
lock_file.write_text(yaml.dump(lock_data))
|
|
197
|
+
|
|
198
|
+
# Load it back
|
|
199
|
+
loaded = load_from_registry("abc123")
|
|
200
|
+
assert loaded == lock_data
|
|
201
|
+
|
|
202
|
+
def test_load_not_found(self, tmp_path):
|
|
203
|
+
"""Test loading non-existent entry."""
|
|
204
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
205
|
+
loaded = load_from_registry("nonexistent")
|
|
206
|
+
assert loaded is None
|
|
207
|
+
|
|
208
|
+
def test_load_corrupted(self, tmp_path):
|
|
209
|
+
"""Test loading corrupted lock file."""
|
|
210
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
211
|
+
registry_dir = get_registry_dir()
|
|
212
|
+
digest_dir = registry_dir / "bad"
|
|
213
|
+
digest_dir.mkdir(parents=True)
|
|
214
|
+
|
|
215
|
+
lock_file = digest_dir / "hud.lock.yaml"
|
|
216
|
+
lock_file.write_text("invalid: yaml: content:")
|
|
217
|
+
|
|
218
|
+
loaded = load_from_registry("bad")
|
|
219
|
+
assert loaded is None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TestListRegistryEntries:
|
|
223
|
+
"""Test listing registry entries."""
|
|
224
|
+
|
|
225
|
+
def test_list_empty(self, tmp_path):
|
|
226
|
+
"""Test listing empty registry."""
|
|
227
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
228
|
+
entries = list_registry_entries()
|
|
229
|
+
assert entries == []
|
|
230
|
+
|
|
231
|
+
def test_list_entries(self, tmp_path):
|
|
232
|
+
"""Test listing multiple entries."""
|
|
233
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
234
|
+
registry_dir = get_registry_dir()
|
|
235
|
+
|
|
236
|
+
# Create several entries
|
|
237
|
+
for digest in ["abc123", "def456", "ghi789"]:
|
|
238
|
+
digest_dir = registry_dir / digest
|
|
239
|
+
digest_dir.mkdir(parents=True)
|
|
240
|
+
lock_file = digest_dir / "hud.lock.yaml"
|
|
241
|
+
lock_file.write_text(f"image: test:{digest}")
|
|
242
|
+
|
|
243
|
+
# Create a directory without lock file (should be ignored)
|
|
244
|
+
(registry_dir / "nolockfile").mkdir(parents=True)
|
|
245
|
+
|
|
246
|
+
# Create a file in registry dir (should be ignored)
|
|
247
|
+
(registry_dir / "README.txt").write_text("info")
|
|
248
|
+
|
|
249
|
+
entries = list_registry_entries()
|
|
250
|
+
|
|
251
|
+
assert len(entries) == 3
|
|
252
|
+
digests = [entry[0] for entry in entries]
|
|
253
|
+
assert set(digests) == {"abc123", "def456", "ghi789"}
|
|
254
|
+
|
|
255
|
+
# Verify all paths are lock files
|
|
256
|
+
for _, lock_path in entries:
|
|
257
|
+
assert lock_path.name == "hud.lock.yaml"
|
|
258
|
+
assert lock_path.exists()
|
|
259
|
+
|
|
260
|
+
def test_list_no_registry_dir(self, tmp_path):
|
|
261
|
+
"""Test listing when registry directory doesn't exist."""
|
|
262
|
+
with mock.patch("pathlib.Path.home", return_value=tmp_path / "nonexistent"):
|
|
263
|
+
entries = list_registry_entries()
|
|
264
|
+
assert entries == []
|
hud/clients/base.py
CHANGED
|
@@ -101,6 +101,7 @@ class BaseHUDClient(AgentMCPClient):
|
|
|
101
101
|
self._mcp_config = mcp_config
|
|
102
102
|
self._strict_validation = strict_validation
|
|
103
103
|
self._auto_trace = auto_trace
|
|
104
|
+
self._auto_trace_cm: Any | None = None # Store auto-created trace context manager
|
|
104
105
|
|
|
105
106
|
self._initialized = False
|
|
106
107
|
self._telemetry_data = {} # Initialize telemetry data
|
|
@@ -124,7 +125,7 @@ class BaseHUDClient(AgentMCPClient):
|
|
|
124
125
|
"Either pass it to the constructor or call initialize with a configuration"
|
|
125
126
|
)
|
|
126
127
|
|
|
127
|
-
setup_hud_telemetry(self._mcp_config, auto_trace=self._auto_trace)
|
|
128
|
+
self._auto_trace_cm = setup_hud_telemetry(self._mcp_config, auto_trace=self._auto_trace)
|
|
128
129
|
|
|
129
130
|
logger.debug("Initializing MCP client...")
|
|
130
131
|
|
|
@@ -163,6 +164,17 @@ class BaseHUDClient(AgentMCPClient):
|
|
|
163
164
|
|
|
164
165
|
async def shutdown(self) -> None:
|
|
165
166
|
"""Disconnect from the MCP server."""
|
|
167
|
+
# Clean up auto-created trace if any
|
|
168
|
+
if self._auto_trace_cm:
|
|
169
|
+
try:
|
|
170
|
+
self._auto_trace_cm.__exit__(None, None, None)
|
|
171
|
+
logger.info("Closed auto-created trace")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.warning("Failed to close auto-created trace: %s", e)
|
|
174
|
+
finally:
|
|
175
|
+
self._auto_trace_cm = None
|
|
176
|
+
|
|
177
|
+
# Disconnect from server
|
|
166
178
|
if self._initialized:
|
|
167
179
|
await self._disconnect()
|
|
168
180
|
self._initialized = False
|