hud-python 0.4.8__py3-none-any.whl → 0.4.10__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,288 @@
1
+ """Tests for list_func.py - List HUD environments from local registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import time
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ import pytest
13
+ import yaml
14
+
15
+ from hud.cli.list_func import format_timestamp, list_environments, list_command
16
+
17
+
18
+ class TestFormatTimestamp:
19
+ """Test timestamp formatting functionality."""
20
+
21
+ def test_format_timestamp_just_now(self):
22
+ """Test formatting for very recent timestamps."""
23
+ now = time.time()
24
+ assert format_timestamp(now) == "just now"
25
+
26
+ def test_format_timestamp_minutes(self):
27
+ """Test formatting for timestamps in minutes."""
28
+ now = datetime.now()
29
+
30
+ # 5 minutes ago
31
+ timestamp = (now - timedelta(minutes=5)).timestamp()
32
+ assert format_timestamp(timestamp) == "5m ago"
33
+
34
+ # 45 minutes ago
35
+ timestamp = (now - timedelta(minutes=45)).timestamp()
36
+ assert format_timestamp(timestamp) == "45m ago"
37
+
38
+ def test_format_timestamp_hours(self):
39
+ """Test formatting for timestamps in hours."""
40
+ now = datetime.now()
41
+
42
+ # 2 hours ago
43
+ timestamp = (now - timedelta(hours=2)).timestamp()
44
+ assert format_timestamp(timestamp) == "2h ago"
45
+
46
+ # 23 hours ago
47
+ timestamp = (now - timedelta(hours=23)).timestamp()
48
+ assert format_timestamp(timestamp) == "23h ago"
49
+
50
+ def test_format_timestamp_days(self):
51
+ """Test formatting for timestamps in days."""
52
+ now = datetime.now()
53
+
54
+ # 3 days ago
55
+ timestamp = (now - timedelta(days=3)).timestamp()
56
+ assert format_timestamp(timestamp) == "3d ago"
57
+
58
+ # 29 days ago
59
+ timestamp = (now - timedelta(days=29)).timestamp()
60
+ assert format_timestamp(timestamp) == "29d ago"
61
+
62
+ def test_format_timestamp_months(self):
63
+ """Test formatting for timestamps in months."""
64
+ now = datetime.now()
65
+
66
+ # 2 months ago
67
+ timestamp = (now - timedelta(days=60)).timestamp()
68
+ assert format_timestamp(timestamp) == "2mo ago"
69
+
70
+ # 11 months ago
71
+ timestamp = (now - timedelta(days=335)).timestamp()
72
+ assert format_timestamp(timestamp) == "11mo ago"
73
+
74
+ def test_format_timestamp_years(self):
75
+ """Test formatting for timestamps in years."""
76
+ now = datetime.now()
77
+
78
+ # 1 year ago
79
+ timestamp = (now - timedelta(days=400)).timestamp()
80
+ assert format_timestamp(timestamp) == "1y ago"
81
+
82
+ # 3 years ago
83
+ timestamp = (now - timedelta(days=1100)).timestamp()
84
+ assert format_timestamp(timestamp) == "3y ago"
85
+
86
+ def test_format_timestamp_none(self):
87
+ """Test formatting when timestamp is None."""
88
+ assert format_timestamp(None) == "unknown"
89
+
90
+
91
+ class TestListEnvironments:
92
+ """Test listing environments functionality."""
93
+
94
+ @pytest.fixture
95
+ def mock_registry_dir(self, tmp_path):
96
+ """Create a mock registry directory with sample environments."""
97
+ registry_dir = tmp_path / ".hud" / "envs"
98
+ registry_dir.mkdir(parents=True)
99
+
100
+ # Create sample environments (use underscore instead of colon for Windows compatibility)
101
+ env1_dir = registry_dir / "sha256_abc123"
102
+ env1_dir.mkdir()
103
+ lock1 = env1_dir / "hud.lock.yaml"
104
+ lock1.write_text(yaml.dump({
105
+ "image": "test/env1:latest",
106
+ "metadata": {
107
+ "description": "Test environment 1",
108
+ "tools": ["tool1", "tool2", "tool3"]
109
+ }
110
+ }))
111
+ # Set modification time to 1 hour ago
112
+ old_time = time.time() - 3600
113
+ lock1.touch()
114
+
115
+ env2_dir = registry_dir / "sha256_def456"
116
+ env2_dir.mkdir()
117
+ lock2 = env2_dir / "hud.lock.yaml"
118
+ lock2.write_text(yaml.dump({
119
+ "image": "test/env2:v1.0",
120
+ "metadata": {
121
+ "description": "Test environment 2 with a much longer description that should be truncated",
122
+ "tools": ["tool1"]
123
+ }
124
+ }))
125
+
126
+ return registry_dir
127
+
128
+ @mock.patch("hud.cli.list_func.get_registry_dir")
129
+ def test_list_no_registry(self, mock_get_registry):
130
+ """Test listing when registry doesn't exist."""
131
+ mock_get_registry.return_value = Path("/nonexistent")
132
+
133
+ # Just test it runs without error
134
+ list_environments()
135
+
136
+ # The design messages will be printed but we don't need to test them
137
+
138
+ @mock.patch("hud.cli.list_func.get_registry_dir")
139
+ def test_list_json_no_registry(self, mock_get_registry, capsys):
140
+ """Test JSON output when registry doesn't exist."""
141
+ mock_get_registry.return_value = Path("/nonexistent")
142
+
143
+ list_environments(json_output=True)
144
+
145
+ captured = capsys.readouterr()
146
+ assert captured.out.strip() == "[]"
147
+
148
+ @mock.patch("hud.cli.list_func.get_registry_dir")
149
+ @mock.patch("hud.cli.list_func.list_registry_entries")
150
+ def test_list_environments_basic(self, mock_list_entries, mock_get_registry, mock_registry_dir):
151
+ """Test basic environment listing."""
152
+ mock_get_registry.return_value = mock_registry_dir
153
+
154
+ # Mock registry entries
155
+ entries = [
156
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
157
+ ("sha256_def456", mock_registry_dir / "sha256_def456" / "hud.lock.yaml"),
158
+ ]
159
+ mock_list_entries.return_value = entries
160
+
161
+ # Just test it runs without error
162
+ list_environments()
163
+
164
+ @mock.patch("hud.cli.list_func.get_registry_dir")
165
+ @mock.patch("hud.cli.list_func.list_registry_entries")
166
+ def test_list_environments_json(self, mock_list_entries, mock_get_registry, mock_registry_dir, capsys):
167
+ """Test JSON output format."""
168
+ mock_get_registry.return_value = mock_registry_dir
169
+
170
+ # Mock registry entries
171
+ entries = [
172
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
173
+ ("sha256_def456", mock_registry_dir / "sha256_def456" / "hud.lock.yaml"),
174
+ ]
175
+ mock_list_entries.return_value = entries
176
+
177
+ list_environments(json_output=True)
178
+
179
+ captured = capsys.readouterr()
180
+ data = json.loads(captured.out)
181
+
182
+ assert len(data) == 2
183
+ # Check we have the expected structure
184
+ assert all(key in data[0] for key in ["name", "tag", "tools_count", "digest"])
185
+
186
+ @mock.patch("hud.cli.list_func.get_registry_dir")
187
+ @mock.patch("hud.cli.list_func.list_registry_entries")
188
+ def test_list_environments_filter(self, mock_list_entries, mock_get_registry, mock_registry_dir, capsys):
189
+ """Test filtering environments by name."""
190
+ mock_get_registry.return_value = mock_registry_dir
191
+
192
+ # Mock registry entries
193
+ entries = [
194
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
195
+ ("sha256_def456", mock_registry_dir / "sha256_def456" / "hud.lock.yaml"),
196
+ ]
197
+ mock_list_entries.return_value = entries
198
+
199
+ # Filter for env1 and check JSON output
200
+ list_environments(filter_name="env1", json_output=True)
201
+
202
+ captured = capsys.readouterr()
203
+ data = json.loads(captured.out)
204
+ # Should only have env1
205
+ assert len(data) == 1
206
+ assert "env1" in data[0]["name"]
207
+
208
+ @mock.patch("hud.cli.list_func.get_registry_dir")
209
+ @mock.patch("hud.cli.list_func.list_registry_entries")
210
+ def test_list_environments_verbose(self, mock_list_entries, mock_get_registry, mock_registry_dir):
211
+ """Test verbose output."""
212
+ mock_get_registry.return_value = mock_registry_dir
213
+
214
+ # Mock registry entries
215
+ entries = [
216
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
217
+ ]
218
+ mock_list_entries.return_value = entries
219
+
220
+ # Just test it runs in verbose mode
221
+ list_environments(verbose=True)
222
+
223
+ @mock.patch("hud.cli.list_func.get_registry_dir")
224
+ @mock.patch("hud.cli.list_func.list_registry_entries")
225
+ def test_list_environments_with_errors(self, mock_list_entries, mock_get_registry, mock_registry_dir, capsys):
226
+ """Test handling of corrupted lock files."""
227
+ mock_get_registry.return_value = mock_registry_dir
228
+
229
+ # Create a bad lock file
230
+ bad_dir = mock_registry_dir / "sha256_bad"
231
+ bad_dir.mkdir()
232
+ bad_lock = bad_dir / "hud.lock.yaml"
233
+ bad_lock.write_text("invalid: yaml: content:")
234
+
235
+ # Mock registry entries including the bad one
236
+ entries = [
237
+ ("sha256_bad", bad_lock),
238
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
239
+ ("sha256_def456", mock_registry_dir / "sha256_def456" / "hud.lock.yaml"),
240
+ ]
241
+ mock_list_entries.return_value = entries
242
+
243
+ # Should handle error gracefully in verbose mode
244
+ list_environments(verbose=True, json_output=True)
245
+
246
+ captured = capsys.readouterr()
247
+ data = json.loads(captured.out)
248
+ # Should still list the valid environments
249
+ assert len(data) == 2 # Only the 2 valid ones
250
+
251
+ @mock.patch("hud.cli.list_func.get_registry_dir")
252
+ @mock.patch("hud.cli.list_func.list_registry_entries")
253
+ def test_list_environments_no_matches(self, mock_list_entries, mock_get_registry, mock_registry_dir):
254
+ """Test when no environments match the filter."""
255
+ mock_get_registry.return_value = mock_registry_dir
256
+
257
+ # Mock registry entries
258
+ entries = [
259
+ ("sha256_abc123", mock_registry_dir / "sha256_abc123" / "hud.lock.yaml"),
260
+ ("sha256_def456", mock_registry_dir / "sha256_def456" / "hud.lock.yaml"),
261
+ ]
262
+ mock_list_entries.return_value = entries
263
+
264
+ # Filter for non-existent env
265
+ list_environments(filter_name="nonexistent")
266
+
267
+ # Just test it runs without error
268
+
269
+
270
+ class TestListCommand:
271
+ """Test the CLI command wrapper."""
272
+
273
+ def test_list_command_basic(self):
274
+ """Test basic list command runs without error."""
275
+ # Just test it doesn't crash
276
+ # Note: we can't easily test the exact arguments because Typer
277
+ # passes OptionInfo objects as defaults
278
+ list_command()
279
+
280
+ def test_list_command_with_options(self):
281
+ """Test list command with options runs without error."""
282
+ # Just test it doesn't crash with explicit values
283
+ list_command(
284
+ filter_name="test",
285
+ json_output=True,
286
+ show_all=True,
287
+ verbose=True
288
+ )
@@ -0,0 +1,400 @@
1
+ """Tests for pull.py - Pull HUD environments from registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ import pytest
11
+ import typer
12
+ import yaml
13
+
14
+ from hud.cli.pull import (
15
+ fetch_lock_from_registry,
16
+ format_size,
17
+ get_docker_manifest,
18
+ get_image_size_from_manifest,
19
+ pull_command,
20
+ pull_environment,
21
+ )
22
+
23
+
24
+ class TestGetDockerManifest:
25
+ """Test getting Docker manifest."""
26
+
27
+ @mock.patch("subprocess.run")
28
+ def test_get_docker_manifest_success(self, mock_run):
29
+ """Test successfully getting Docker manifest."""
30
+ manifest_data = {
31
+ "schemaVersion": 2,
32
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
33
+ "layers": [
34
+ {"size": 1024},
35
+ {"size": 2048}
36
+ ]
37
+ }
38
+ mock_run.return_value = mock.Mock(
39
+ returncode=0,
40
+ stdout=json.dumps(manifest_data)
41
+ )
42
+
43
+ result = get_docker_manifest("test:latest")
44
+ assert result == manifest_data
45
+ mock_run.assert_called_once_with(
46
+ ["docker", "manifest", "inspect", "test:latest"],
47
+ capture_output=True,
48
+ text=True
49
+ )
50
+
51
+ @mock.patch("subprocess.run")
52
+ def test_get_docker_manifest_failure(self, mock_run):
53
+ """Test failed Docker manifest fetch."""
54
+ mock_run.return_value = mock.Mock(returncode=1, stdout="")
55
+
56
+ result = get_docker_manifest("test:latest")
57
+ assert result is None
58
+
59
+ @mock.patch("subprocess.run")
60
+ def test_get_docker_manifest_exception(self, mock_run):
61
+ """Test Docker manifest fetch with exception."""
62
+ mock_run.side_effect = Exception("Command failed")
63
+
64
+ result = get_docker_manifest("test:latest")
65
+ assert result is None
66
+
67
+
68
+ class TestGetImageSizeFromManifest:
69
+ """Test extracting image size from manifest."""
70
+
71
+ def test_get_size_v2_manifest(self):
72
+ """Test getting size from v2 manifest with layers."""
73
+ manifest = {
74
+ "layers": [
75
+ {"size": 1024},
76
+ {"size": 2048},
77
+ {"size": 512}
78
+ ]
79
+ }
80
+
81
+ size = get_image_size_from_manifest(manifest)
82
+ assert size == 3584 # Sum of all layers
83
+
84
+ def test_get_size_manifest_list(self):
85
+ """Test getting size from manifest list."""
86
+ manifest = {
87
+ "manifests": [
88
+ {"size": 5120, "platform": {"os": "linux"}},
89
+ {"size": 4096, "platform": {"os": "windows"}}
90
+ ]
91
+ }
92
+
93
+ size = get_image_size_from_manifest(manifest)
94
+ assert size == 5120 # First manifest size
95
+
96
+ def test_get_size_empty_manifest(self):
97
+ """Test getting size from empty manifest."""
98
+ manifest = {}
99
+
100
+ size = get_image_size_from_manifest(manifest)
101
+ assert size is None
102
+
103
+ def test_get_size_invalid_manifest(self):
104
+ """Test getting size from invalid manifest."""
105
+ manifest = {"invalid": "data"}
106
+
107
+ size = get_image_size_from_manifest(manifest)
108
+ assert size is None
109
+
110
+
111
+ class TestFetchLockFromRegistry:
112
+ """Test fetching lock data from HUD registry."""
113
+
114
+ @mock.patch("requests.get")
115
+ def test_fetch_lock_success(self, mock_get):
116
+ """Test successful lock file fetch."""
117
+ lock_data = {"test": "data"}
118
+ mock_response = mock.Mock()
119
+ mock_response.status_code = 200
120
+ mock_response.json.return_value = {"lock": yaml.dump(lock_data)}
121
+ mock_get.return_value = mock_response
122
+
123
+ result = fetch_lock_from_registry("org/env:latest")
124
+ assert result == lock_data
125
+
126
+ @mock.patch("requests.get")
127
+ def test_fetch_lock_adds_latest_tag(self, mock_get):
128
+ """Test that :latest is added if missing."""
129
+ mock_response = mock.Mock()
130
+ mock_response.status_code = 200
131
+ mock_response.json.return_value = {"lock_data": {"test": "data"}}
132
+ mock_get.return_value = mock_response
133
+
134
+ fetch_lock_from_registry("org/env")
135
+
136
+ # Check URL includes :latest
137
+ call_args = mock_get.call_args
138
+ assert "org/env:latest" in call_args[0][0]
139
+
140
+ @mock.patch("hud.cli.pull.settings")
141
+ @mock.patch("requests.get")
142
+ def test_fetch_lock_with_auth(self, mock_get, mock_settings):
143
+ """Test fetching with API key."""
144
+ mock_settings.api_key = "test-key"
145
+
146
+ mock_response = mock.Mock()
147
+ mock_response.status_code = 200
148
+ mock_response.json.return_value = {"test": "data"}
149
+ mock_get.return_value = mock_response
150
+
151
+ fetch_lock_from_registry("org/env:latest")
152
+
153
+ # Check auth header was set
154
+ call_kwargs = mock_get.call_args[1]
155
+ assert call_kwargs["headers"]["Authorization"] == "Bearer test-key"
156
+
157
+ @mock.patch("requests.get")
158
+ def test_fetch_lock_failure(self, mock_get):
159
+ """Test failed lock file fetch."""
160
+ mock_response = mock.Mock()
161
+ mock_response.status_code = 404
162
+ mock_get.return_value = mock_response
163
+
164
+ result = fetch_lock_from_registry("org/env:latest")
165
+ assert result is None
166
+
167
+
168
+ class TestFormatSize:
169
+ """Test size formatting."""
170
+
171
+ def test_format_bytes(self):
172
+ """Test formatting bytes."""
173
+ assert format_size(512) == "512.0 B"
174
+ assert format_size(1023) == "1023.0 B"
175
+
176
+ def test_format_kilobytes(self):
177
+ """Test formatting kilobytes."""
178
+ assert format_size(1024) == "1.0 KB"
179
+ assert format_size(2048) == "2.0 KB"
180
+
181
+ def test_format_megabytes(self):
182
+ """Test formatting megabytes."""
183
+ assert format_size(1024 * 1024) == "1.0 MB"
184
+ assert format_size(5 * 1024 * 1024) == "5.0 MB"
185
+
186
+ def test_format_gigabytes(self):
187
+ """Test formatting gigabytes."""
188
+ assert format_size(1024 * 1024 * 1024) == "1.0 GB"
189
+ assert format_size(2 * 1024 * 1024 * 1024) == "2.0 GB"
190
+
191
+ def test_format_terabytes(self):
192
+ """Test formatting terabytes."""
193
+ assert format_size(1024 * 1024 * 1024 * 1024) == "1.0 TB"
194
+
195
+
196
+ class TestPullEnvironment:
197
+ """Test the main pull_environment function."""
198
+
199
+ @mock.patch("hud.cli.pull.HUDDesign")
200
+ @mock.patch("hud.cli.pull.save_to_registry")
201
+ @mock.patch("subprocess.Popen")
202
+ def test_pull_with_lock_file(self, mock_popen, mock_save, mock_design_class, tmp_path):
203
+ """Test pulling with a lock file."""
204
+ # Create mock design instance
205
+ mock_design = mock.Mock()
206
+ mock_design.console = mock.Mock()
207
+ mock_design_class.return_value = mock_design
208
+
209
+ # Create lock file
210
+ lock_data = {
211
+ "image": "test/env:latest@sha256:abc123",
212
+ "build": {
213
+ "generatedAt": "2024-01-01T00:00:00Z",
214
+ "hudVersion": "1.0.0"
215
+ },
216
+ "environment": {
217
+ "toolCount": 5,
218
+ "initializeMs": 1500
219
+ },
220
+ "tools": [
221
+ {"name": "tool1", "description": "Tool 1"}
222
+ ]
223
+ }
224
+ lock_file = tmp_path / "hud.lock.yaml"
225
+ lock_file.write_text(yaml.dump(lock_data))
226
+
227
+ # Mock docker pull
228
+ mock_process = mock.Mock()
229
+ mock_process.stdout = ["Pulling test/env:latest...\n", "Pull complete\n"]
230
+ mock_process.wait.return_value = None
231
+ mock_process.returncode = 0
232
+ mock_popen.return_value = mock_process
233
+
234
+ # Run pull
235
+ pull_environment(str(lock_file), yes=True)
236
+
237
+ # Verify docker pull was called
238
+ mock_popen.assert_called_once()
239
+ call_args = mock_popen.call_args[0][0]
240
+ assert call_args == ["docker", "pull", "test/env:latest@sha256:abc123"]
241
+
242
+ # Verify lock was saved to registry
243
+ mock_save.assert_called_once()
244
+
245
+ @mock.patch("hud.cli.pull.HUDDesign")
246
+ @mock.patch("hud.cli.pull.fetch_lock_from_registry")
247
+ @mock.patch("subprocess.Popen")
248
+ def test_pull_from_registry(self, mock_popen, mock_fetch, mock_design_class):
249
+ """Test pulling from HUD registry."""
250
+ # Create mock design instance
251
+ mock_design = mock.Mock()
252
+ mock_design.console = mock.Mock()
253
+ mock_design_class.return_value = mock_design
254
+
255
+ # Mock registry response
256
+ lock_data = {
257
+ "image": "docker.io/org/env:latest@sha256:def456",
258
+ "tools": []
259
+ }
260
+ mock_fetch.return_value = lock_data
261
+
262
+ # Mock docker pull
263
+ mock_process = mock.Mock()
264
+ mock_process.stdout = []
265
+ mock_process.wait.return_value = None
266
+ mock_process.returncode = 0
267
+ mock_popen.return_value = mock_process
268
+
269
+ # Run pull
270
+ pull_environment("org/env:latest", yes=True)
271
+
272
+ # Verify registry was checked
273
+ mock_fetch.assert_called_once_with("org/env:latest")
274
+
275
+ # Verify docker pull was called with registry image
276
+ mock_popen.assert_called_once()
277
+ call_args = mock_popen.call_args[0][0]
278
+ assert "docker.io/org/env:latest@sha256:def456" in call_args
279
+
280
+ @mock.patch("hud.cli.pull.HUDDesign")
281
+ @mock.patch("hud.cli.pull.get_docker_manifest")
282
+ @mock.patch("hud.cli.pull.fetch_lock_from_registry")
283
+ @mock.patch("subprocess.Popen")
284
+ def test_pull_docker_image_direct(self, mock_popen, mock_fetch, mock_manifest, mock_design_class):
285
+ """Test pulling Docker image directly."""
286
+ # Create mock design instance
287
+ mock_design = mock.Mock()
288
+ mock_design.console = mock.Mock()
289
+ mock_design_class.return_value = mock_design
290
+
291
+ # Mock no registry data
292
+ mock_fetch.return_value = None
293
+
294
+ # Mock manifest
295
+ mock_manifest.return_value = {
296
+ "layers": [{"size": 1024}]
297
+ }
298
+
299
+ # Mock docker pull
300
+ mock_process = mock.Mock()
301
+ mock_process.stdout = []
302
+ mock_process.wait.return_value = None
303
+ mock_process.returncode = 0
304
+ mock_popen.return_value = mock_process
305
+
306
+ # Run pull
307
+ pull_environment("ubuntu:latest", yes=True)
308
+
309
+ # Verify docker pull was called
310
+ mock_popen.assert_called_once()
311
+ call_args = mock_popen.call_args[0][0]
312
+ assert call_args == ["docker", "pull", "ubuntu:latest"]
313
+
314
+ @mock.patch("hud.cli.pull.HUDDesign")
315
+ def test_pull_verify_only(self, mock_design_class):
316
+ """Test verify-only mode."""
317
+ # Create mock design instance
318
+ mock_design = mock.Mock()
319
+ mock_design.console = mock.Mock()
320
+ mock_design_class.return_value = mock_design
321
+
322
+ # Should not actually pull
323
+ pull_environment("test:latest", verify_only=True)
324
+
325
+ # Check success message
326
+ mock_design.success.assert_called_with("Verification complete")
327
+
328
+ @mock.patch("hud.cli.pull.HUDDesign")
329
+ @mock.patch("subprocess.Popen")
330
+ def test_pull_docker_failure(self, mock_popen, mock_design_class):
331
+ """Test handling Docker pull failure."""
332
+ # Create mock design instance
333
+ mock_design = mock.Mock()
334
+ mock_design.console = mock.Mock()
335
+ mock_design_class.return_value = mock_design
336
+
337
+ # Mock docker pull failure
338
+ mock_process = mock.Mock()
339
+ mock_process.stdout = ["Error: manifest unknown\n"]
340
+ mock_process.wait.return_value = None
341
+ mock_process.returncode = 1
342
+ mock_popen.return_value = mock_process
343
+
344
+ # Run pull
345
+ with pytest.raises(typer.Exit):
346
+ pull_environment("invalid:image", yes=True)
347
+
348
+ mock_design.error.assert_called_with("Pull failed")
349
+
350
+ @mock.patch("hud.cli.pull.HUDDesign")
351
+ @mock.patch("typer.confirm")
352
+ def test_pull_user_cancels(self, mock_confirm, mock_design_class):
353
+ """Test when user cancels pull."""
354
+ # Create mock design instance
355
+ mock_design = mock.Mock()
356
+ mock_design.console = mock.Mock()
357
+ mock_design_class.return_value = mock_design
358
+
359
+ mock_confirm.return_value = False
360
+
361
+ with pytest.raises(typer.Exit) as exc_info:
362
+ pull_environment("test:latest", yes=False)
363
+
364
+ assert exc_info.value.exit_code == 0
365
+ mock_design.info.assert_called_with("Aborted")
366
+
367
+ @mock.patch("hud.cli.pull.HUDDesign")
368
+ def test_pull_nonexistent_lock_file(self, mock_design_class):
369
+ """Test pulling with non-existent lock file."""
370
+ # Create mock design instance
371
+ mock_design = mock.Mock()
372
+ mock_design.console = mock.Mock()
373
+ mock_design_class.return_value = mock_design
374
+
375
+ with pytest.raises(typer.Exit):
376
+ pull_environment("nonexistent.yaml")
377
+
378
+
379
+ class TestPullCommand:
380
+ """Test the CLI command wrapper."""
381
+
382
+ def test_pull_command_basic(self):
383
+ """Test basic pull command runs without error."""
384
+ # Just test it doesn't crash
385
+ # Note: we can't easily test the exact arguments because Typer
386
+ # passes OptionInfo objects as defaults
387
+ with mock.patch("hud.cli.pull.pull_environment"):
388
+ pull_command("test:latest")
389
+
390
+ def test_pull_command_with_options(self):
391
+ """Test pull command with options runs without error."""
392
+ # Just test it doesn't crash with explicit values
393
+ with mock.patch("hud.cli.pull.pull_environment"):
394
+ pull_command(
395
+ "org/env:v1.0",
396
+ lock_file="lock.yaml",
397
+ yes=True,
398
+ verify_only=True,
399
+ verbose=True
400
+ )