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.

hud/cli/remove.py ADDED
@@ -0,0 +1,200 @@
1
+ """Remove HUD environments from local registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from hud.utils.design import HUDDesign
12
+
13
+ from .registry import get_registry_dir, list_registry_entries, load_from_registry
14
+
15
+
16
+ def remove_environment(
17
+ target: str,
18
+ yes: bool = False,
19
+ verbose: bool = False,
20
+ ) -> None:
21
+ """Remove an environment from the local registry."""
22
+ design = HUDDesign()
23
+ design.header("HUD Environment Removal")
24
+
25
+ # Find the environment to remove
26
+ found_entry = None
27
+ found_digest = None
28
+
29
+ # First check if target is a digest
30
+ for digest, lock_file in list_registry_entries():
31
+ if digest.startswith(target):
32
+ found_entry = lock_file
33
+ found_digest = digest
34
+ break
35
+
36
+ # If not found by digest, search by name
37
+ if not found_entry:
38
+ for digest, lock_file in list_registry_entries():
39
+ try:
40
+ lock_data = load_from_registry(digest)
41
+ if lock_data and "image" in lock_data:
42
+ image = lock_data["image"]
43
+ # Extract name and tag
44
+ name = image.split("@")[0] if "@" in image else image
45
+ if "/" in name:
46
+ # Match by repo/name or just name
47
+ if target in name or name.endswith(f"/{target}"):
48
+ found_entry = lock_file
49
+ found_digest = digest
50
+ break
51
+ except Exception:
52
+ continue
53
+
54
+ if not found_entry:
55
+ design.error(f"Environment not found: {target}")
56
+ design.info("Use 'hud list' to see available environments")
57
+ raise typer.Exit(1)
58
+
59
+ # Load and display environment info
60
+ try:
61
+ lock_data = load_from_registry(found_digest)
62
+ if lock_data:
63
+ image = lock_data.get("image", "unknown")
64
+ metadata = lock_data.get("metadata", {})
65
+ description = metadata.get("description", "No description")
66
+
67
+ design.section_title("Environment Details")
68
+ design.status_item("Image", image)
69
+ design.status_item("Digest", found_digest)
70
+ design.status_item("Description", description)
71
+ design.status_item("Location", str(found_entry.parent))
72
+ except Exception as e:
73
+ if verbose:
74
+ design.warning(f"Could not read environment details: {e}")
75
+
76
+ # Confirm deletion
77
+ if not yes:
78
+ design.info("")
79
+ if not typer.confirm(f"Remove environment {found_digest}?"):
80
+ design.info("Aborted")
81
+ raise typer.Exit(0)
82
+
83
+ # Remove the environment directory
84
+ try:
85
+ env_dir = found_entry.parent
86
+ shutil.rmtree(env_dir)
87
+ design.success(f"Removed environment: {found_digest}")
88
+
89
+ # Check if the image is still available locally
90
+ if lock_data:
91
+ image = lock_data.get("image", "")
92
+ if image:
93
+ design.info("")
94
+ design.info("Note: The Docker image may still exist locally.")
95
+ design.info(f"To remove it, run: [cyan]docker rmi {image.split('@')[0]}[/cyan]")
96
+ except Exception as e:
97
+ design.error(f"Failed to remove environment: {e}")
98
+ raise typer.Exit(1)
99
+
100
+
101
+ def remove_all_environments(
102
+ yes: bool = False,
103
+ verbose: bool = False,
104
+ ) -> None:
105
+ """Remove all environments from the local registry."""
106
+ design = HUDDesign()
107
+ design.header("Remove All HUD Environments")
108
+
109
+ registry_dir = get_registry_dir()
110
+ if not registry_dir.exists():
111
+ design.info("No environments found in local registry.")
112
+ return
113
+
114
+ # Count environments
115
+ entries = list(list_registry_entries())
116
+ if not entries:
117
+ design.info("No environments found in local registry.")
118
+ return
119
+
120
+ design.warning(f"This will remove {len(entries)} environment(s) from the local registry!")
121
+
122
+ # List environments that will be removed
123
+ design.section_title("Environments to Remove")
124
+ for digest, lock_file in entries:
125
+ try:
126
+ lock_data = load_from_registry(digest)
127
+ if lock_data:
128
+ image = lock_data.get("image", "unknown")
129
+ design.info(f" • {digest[:12]} - {image}")
130
+ except Exception:
131
+ design.info(f" • {digest[:12]}")
132
+
133
+ # Confirm deletion
134
+ if not yes:
135
+ design.info("")
136
+ if not typer.confirm("Remove ALL environments?", default=False):
137
+ design.info("Aborted")
138
+ raise typer.Exit(0)
139
+
140
+ # Remove all environments
141
+ removed = 0
142
+ failed = 0
143
+
144
+ for digest, lock_file in entries:
145
+ try:
146
+ env_dir = lock_file.parent
147
+ shutil.rmtree(env_dir)
148
+ removed += 1
149
+ if verbose:
150
+ design.success(f"Removed: {digest}")
151
+ except Exception as e:
152
+ failed += 1
153
+ if verbose:
154
+ design.error(f"Failed to remove {digest}: {e}")
155
+
156
+ design.info("")
157
+ if failed == 0:
158
+ design.success(f"Successfully removed {removed} environment(s)")
159
+ else:
160
+ design.warning(f"Removed {removed} environment(s), failed to remove {failed}")
161
+
162
+ design.info("")
163
+ design.info("Note: Docker images may still exist locally.")
164
+ design.info("To remove them, use: [cyan]docker image prune[/cyan]")
165
+
166
+
167
+ def remove_command(
168
+ target: str | None = typer.Argument(
169
+ None,
170
+ help="Environment to remove (digest, name, or 'all' for all environments)"
171
+ ),
172
+ yes: bool = typer.Option(
173
+ False, "--yes", "-y", help="Skip confirmation prompt"
174
+ ),
175
+ verbose: bool = typer.Option(
176
+ False, "--verbose", "-v", help="Show detailed output"
177
+ ),
178
+ ) -> None:
179
+ """🗑️ Remove HUD environments from local registry.
180
+
181
+ Removes environment metadata from ~/.hud/envs/
182
+ Note: This does not remove the Docker images.
183
+
184
+ Examples:
185
+ hud remove abc123 # Remove by digest
186
+ hud remove text_2048 # Remove by name
187
+ hud remove hudpython/test_init # Remove by full name
188
+ hud remove all # Remove all environments
189
+ hud remove all --yes # Remove all without confirmation
190
+ """
191
+ if not target:
192
+ design = HUDDesign()
193
+ design.error("Please specify an environment to remove or 'all'")
194
+ design.info("Use 'hud list' to see available environments")
195
+ raise typer.Exit(1)
196
+
197
+ if target.lower() == "all":
198
+ remove_all_environments(yes, verbose)
199
+ else:
200
+ remove_environment(target, yes, verbose)
@@ -0,0 +1,277 @@
1
+ """Tests for analyze_metadata.py - Fast metadata analysis functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import pytest
12
+ import yaml
13
+
14
+ from hud.cli.analyze_metadata import (
15
+ analyze_from_metadata,
16
+ check_local_cache,
17
+ fetch_lock_from_registry,
18
+ )
19
+ from hud.cli.registry import save_to_registry
20
+
21
+
22
+ @pytest.fixture
23
+ def mock_registry_dir(tmp_path):
24
+ """Create a mock registry directory."""
25
+ registry_dir = tmp_path / ".hud" / "envs"
26
+ registry_dir.mkdir(parents=True)
27
+ return registry_dir
28
+
29
+
30
+ @pytest.fixture
31
+ def sample_lock_data():
32
+ """Sample lock data for testing."""
33
+ return {
34
+ "image": "test/environment:latest",
35
+ "digest": "sha256:abc123",
36
+ "build": {
37
+ "timestamp": 1234567890,
38
+ "version": "1.0.0",
39
+ "hud_version": "0.1.0",
40
+ },
41
+ "environment": {
42
+ "initializeMs": 1500,
43
+ "toolCount": 5,
44
+ "variables": {"API_KEY": "required"},
45
+ },
46
+ "tools": [
47
+ {
48
+ "name": "test_tool",
49
+ "description": "A test tool",
50
+ "inputSchema": {
51
+ "type": "object",
52
+ "properties": {"message": {"type": "string"}},
53
+ },
54
+ }
55
+ ],
56
+ "resources": [
57
+ {
58
+ "uri": "test://resource",
59
+ "name": "Test Resource",
60
+ "description": "A test resource",
61
+ "mimeType": "text/plain",
62
+ }
63
+ ],
64
+ "prompts": [
65
+ {
66
+ "name": "test_prompt",
67
+ "description": "A test prompt",
68
+ "arguments": [{"name": "arg1", "description": "First argument"}],
69
+ }
70
+ ],
71
+ }
72
+
73
+
74
+ class TestFetchLockFromRegistry:
75
+ """Test fetching lock data from registry."""
76
+
77
+ @mock.patch("requests.get")
78
+ def test_fetch_lock_success(self, mock_get):
79
+ """Test successful fetch from registry."""
80
+ mock_response = mock.Mock()
81
+ mock_response.status_code = 200
82
+ mock_response.json.return_value = {
83
+ "lock": yaml.dump({"test": "data"})
84
+ }
85
+ mock_get.return_value = mock_response
86
+
87
+ result = fetch_lock_from_registry("test/env:latest")
88
+ assert result == {"test": "data"}
89
+ mock_get.assert_called_once()
90
+
91
+ @mock.patch("requests.get")
92
+ def test_fetch_lock_with_lock_data(self, mock_get):
93
+ """Test fetch when response has lock_data key."""
94
+ mock_response = mock.Mock()
95
+ mock_response.status_code = 200
96
+ mock_response.json.return_value = {
97
+ "lock_data": {"test": "data"}
98
+ }
99
+ mock_get.return_value = mock_response
100
+
101
+ result = fetch_lock_from_registry("test/env:latest")
102
+ assert result == {"test": "data"}
103
+
104
+ @mock.patch("requests.get")
105
+ def test_fetch_lock_direct_data(self, mock_get):
106
+ """Test fetch when response is direct lock data."""
107
+ mock_response = mock.Mock()
108
+ mock_response.status_code = 200
109
+ mock_response.json.return_value = {"test": "data"}
110
+ mock_get.return_value = mock_response
111
+
112
+ result = fetch_lock_from_registry("test/env:latest")
113
+ assert result == {"test": "data"}
114
+
115
+ @mock.patch("requests.get")
116
+ def test_fetch_lock_adds_latest_tag(self, mock_get):
117
+ """Test that :latest tag is added if missing."""
118
+ mock_response = mock.Mock()
119
+ mock_response.status_code = 404
120
+ mock_get.return_value = mock_response
121
+
122
+ fetch_lock_from_registry("test/env")
123
+
124
+ # Check that the URL includes :latest
125
+ call_args = mock_get.call_args
126
+ assert "test/env:latest" in call_args[0][0]
127
+
128
+ @mock.patch("requests.get")
129
+ def test_fetch_lock_failure(self, mock_get):
130
+ """Test fetch failure returns None."""
131
+ mock_response = mock.Mock()
132
+ mock_response.status_code = 404
133
+ mock_get.return_value = mock_response
134
+
135
+ result = fetch_lock_from_registry("test/env:latest")
136
+ assert result is None
137
+
138
+ @mock.patch("requests.get")
139
+ def test_fetch_lock_exception(self, mock_get):
140
+ """Test fetch exception returns None."""
141
+ mock_get.side_effect = Exception("Network error")
142
+
143
+ result = fetch_lock_from_registry("test/env:latest")
144
+ assert result is None
145
+
146
+
147
+ class TestCheckLocalCache:
148
+ """Test checking local cache for lock data."""
149
+
150
+ def test_check_local_cache_found(self, mock_registry_dir, sample_lock_data, monkeypatch):
151
+ """Test finding lock data in local cache."""
152
+ # Mock registry directory
153
+ monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
154
+
155
+ # Save sample data to registry
156
+ save_to_registry(sample_lock_data, "test/environment:latest", verbose=False)
157
+
158
+ # Check cache
159
+ result = check_local_cache("test/environment:latest")
160
+ assert result is not None
161
+ assert result["image"] == "test/environment:latest"
162
+
163
+ def test_check_local_cache_not_found(self, mock_registry_dir, monkeypatch):
164
+ """Test when lock data not in local cache."""
165
+ monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
166
+
167
+ result = check_local_cache("nonexistent/env:latest")
168
+ assert result is None
169
+
170
+ def test_check_local_cache_invalid_yaml(self, mock_registry_dir, monkeypatch):
171
+ """Test when lock file has invalid YAML."""
172
+ monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
173
+
174
+ # Create invalid lock file
175
+ digest = "sha256:invalid"
176
+ lock_file = mock_registry_dir / digest / "hud.lock.yaml"
177
+ lock_file.parent.mkdir(parents=True)
178
+ lock_file.write_text("invalid: yaml: content:")
179
+
180
+ result = check_local_cache("test/invalid:latest")
181
+ assert result is None
182
+
183
+
184
+ # Note: TestFormatAnalysisOutput class removed since format_analysis_output function doesn't exist
185
+ # The formatting is done inline within analyze_from_metadata
186
+
187
+
188
+ @pytest.mark.asyncio
189
+ class TestAnalyzeFromMetadata:
190
+ """Test the main analyze_from_metadata function."""
191
+
192
+ @mock.patch("hud.cli.analyze_metadata.check_local_cache")
193
+ @mock.patch("hud.cli.analyze_metadata.console")
194
+ async def test_analyze_from_local_cache(self, mock_console, mock_check, sample_lock_data):
195
+ """Test analyzing from local cache."""
196
+ mock_check.return_value = sample_lock_data
197
+
198
+ await analyze_from_metadata("test/env:latest", "json", verbose=False)
199
+
200
+ mock_check.assert_called_once_with("test/env:latest")
201
+ # Should output JSON
202
+ mock_console.print_json.assert_called_once()
203
+
204
+ @mock.patch("hud.cli.analyze_metadata.check_local_cache")
205
+ @mock.patch("hud.cli.analyze_metadata.fetch_lock_from_registry")
206
+ @mock.patch("hud.cli.analyze_metadata.save_to_registry")
207
+ @mock.patch("hud.cli.analyze_metadata.console")
208
+ async def test_analyze_from_registry(
209
+ self, mock_console, mock_save, mock_fetch, mock_check, sample_lock_data
210
+ ):
211
+ """Test analyzing from registry when not in cache."""
212
+ mock_check.return_value = None
213
+ mock_fetch.return_value = sample_lock_data
214
+
215
+ await analyze_from_metadata("test/env:latest", "json", verbose=False)
216
+
217
+ mock_check.assert_called_once()
218
+ mock_fetch.assert_called_once()
219
+ mock_save.assert_called_once() # Should save to cache
220
+ mock_console.print_json.assert_called_once()
221
+
222
+ @mock.patch("hud.cli.analyze_metadata.check_local_cache")
223
+ @mock.patch("hud.cli.analyze_metadata.fetch_lock_from_registry")
224
+ @mock.patch("hud.cli.analyze_metadata.design")
225
+ @mock.patch("hud.cli.analyze_metadata.console")
226
+ async def test_analyze_not_found(self, mock_console, mock_design, mock_fetch, mock_check):
227
+ """Test when environment not found anywhere."""
228
+ mock_check.return_value = None
229
+ mock_fetch.return_value = None
230
+
231
+ await analyze_from_metadata("test/notfound:latest", "json", verbose=False)
232
+
233
+ # Should show error
234
+ mock_design.error.assert_called_with("Environment metadata not found")
235
+ # Should print suggestions
236
+ mock_console.print.assert_called()
237
+
238
+ @mock.patch("hud.cli.analyze_metadata.check_local_cache")
239
+ @mock.patch("hud.cli.analyze_metadata.console")
240
+ async def test_analyze_verbose_mode(self, mock_console, mock_check, sample_lock_data):
241
+ """Test verbose mode includes input schemas."""
242
+ mock_check.return_value = sample_lock_data
243
+
244
+ await analyze_from_metadata("test/env:latest", "json", verbose=True)
245
+
246
+ # In verbose mode, the JSON output should include input schemas
247
+ mock_console.print_json.assert_called_once()
248
+ # Get the JSON string that was printed
249
+ call_args = mock_console.print_json.call_args[0][0]
250
+ output_data = json.loads(call_args)
251
+ assert "inputSchema" in output_data["tools"][0]
252
+
253
+ @mock.patch("hud.cli.analyze_metadata.check_local_cache")
254
+ @mock.patch("hud.cli.analyze_metadata.fetch_lock_from_registry")
255
+ async def test_analyze_registry_reference_parsing(self, mock_fetch, mock_check):
256
+ """Test parsing of different registry reference formats."""
257
+ mock_check.return_value = None
258
+ mock_fetch.return_value = {"test": "data"}
259
+
260
+ # Test different reference formats
261
+ test_cases = [
262
+ ("docker.io/org/name:tag", "org/name:tag"),
263
+ ("registry-1.docker.io/org/name", "org/name"),
264
+ ("org/name@sha256:abc", "org/name"),
265
+ ("org/name", "org/name"),
266
+ ("name:tag", "name:tag"),
267
+ ]
268
+
269
+ for input_ref, expected_call in test_cases:
270
+ await analyze_from_metadata(input_ref, "json", verbose=False)
271
+
272
+ # Check what was passed to fetch_lock_from_registry
273
+ calls = mock_fetch.call_args_list
274
+ last_call = calls[-1][0][0]
275
+
276
+ # The function might add :latest, so check base name
277
+ assert expected_call.split(":")[0] in last_call