hud-python 0.4.8__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.

@@ -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
hud/tools/__init__.py CHANGED
@@ -8,6 +8,7 @@ from .base import BaseHub, BaseTool
8
8
  from .bash import BashTool
9
9
  from .edit import EditTool
10
10
  from .playwright import PlaywrightTool
11
+ from .response import ResponseTool
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from .computer import AnthropicComputerTool, HudComputerTool, OpenAIComputerTool
@@ -21,6 +22,7 @@ __all__ = [
21
22
  "HudComputerTool",
22
23
  "OpenAIComputerTool",
23
24
  "PlaywrightTool",
25
+ "ResponseTool",
24
26
  ]
25
27
 
26
28