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.
- hud/agents/base.py +50 -1
- hud/cli/__init__.py +187 -11
- hud/cli/analyze_metadata.py +33 -42
- hud/cli/build.py +7 -0
- hud/cli/debug.py +8 -1
- hud/cli/env_utils.py +133 -0
- hud/cli/eval.py +302 -0
- hud/cli/list_func.py +213 -0
- hud/cli/mcp_server.py +3 -79
- hud/cli/pull.py +20 -15
- hud/cli/push.py +84 -41
- hud/cli/registry.py +155 -0
- hud/cli/remove.py +200 -0
- hud/cli/runner.py +1 -1
- 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/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.8.dist-info → hud_python-0.4.10.dist-info}/METADATA +12 -1
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/RECORD +32 -20
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/WHEEL +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
)
|