hud-python 0.4.11__py3-none-any.whl → 0.4.12__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.

Files changed (63) hide show
  1. hud/__main__.py +8 -0
  2. hud/agents/base.py +7 -8
  3. hud/agents/langchain.py +2 -2
  4. hud/agents/tests/test_openai.py +3 -1
  5. hud/cli/__init__.py +106 -51
  6. hud/cli/build.py +121 -71
  7. hud/cli/debug.py +2 -2
  8. hud/cli/{mcp_server.py → dev.py} +60 -25
  9. hud/cli/eval.py +148 -68
  10. hud/cli/init.py +0 -1
  11. hud/cli/list_func.py +72 -71
  12. hud/cli/pull.py +1 -2
  13. hud/cli/push.py +35 -23
  14. hud/cli/remove.py +35 -41
  15. hud/cli/tests/test_analyze.py +2 -1
  16. hud/cli/tests/test_analyze_metadata.py +42 -49
  17. hud/cli/tests/test_build.py +28 -52
  18. hud/cli/tests/test_cursor.py +1 -1
  19. hud/cli/tests/test_debug.py +1 -1
  20. hud/cli/tests/test_list_func.py +75 -64
  21. hud/cli/tests/test_main_module.py +30 -0
  22. hud/cli/tests/test_mcp_server.py +3 -3
  23. hud/cli/tests/test_pull.py +30 -61
  24. hud/cli/tests/test_push.py +70 -89
  25. hud/cli/tests/test_registry.py +36 -38
  26. hud/cli/tests/test_utils.py +1 -1
  27. hud/cli/utils/__init__.py +1 -0
  28. hud/cli/{docker_utils.py → utils/docker.py} +36 -0
  29. hud/cli/{env_utils.py → utils/environment.py} +7 -7
  30. hud/cli/{interactive.py → utils/interactive.py} +91 -19
  31. hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
  32. hud/cli/{registry.py → utils/registry.py} +28 -30
  33. hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
  34. hud/cli/utils/runner.py +134 -0
  35. hud/cli/utils/server.py +250 -0
  36. hud/clients/base.py +1 -1
  37. hud/clients/fastmcp.py +7 -5
  38. hud/clients/mcp_use.py +8 -6
  39. hud/server/server.py +34 -4
  40. hud/shared/exceptions.py +11 -0
  41. hud/shared/tests/test_exceptions.py +22 -0
  42. hud/telemetry/tests/__init__.py +0 -0
  43. hud/telemetry/tests/test_replay.py +40 -0
  44. hud/telemetry/tests/test_trace.py +63 -0
  45. hud/tools/base.py +20 -3
  46. hud/tools/computer/hud.py +15 -6
  47. hud/tools/executors/tests/test_base_executor.py +27 -0
  48. hud/tools/response.py +12 -8
  49. hud/tools/tests/test_response.py +60 -0
  50. hud/tools/tests/test_tools_init.py +49 -0
  51. hud/utils/design.py +19 -8
  52. hud/utils/mcp.py +17 -5
  53. hud/utils/tests/test_mcp.py +112 -0
  54. hud/utils/tests/test_version.py +1 -1
  55. hud/version.py +1 -1
  56. {hud_python-0.4.11.dist-info → hud_python-0.4.12.dist-info}/METADATA +14 -10
  57. {hud_python-0.4.11.dist-info → hud_python-0.4.12.dist-info}/RECORD +62 -52
  58. hud/cli/runner.py +0 -160
  59. /hud/cli/{cursor.py → utils/cursor.py} +0 -0
  60. /hud/cli/{utils.py → utils/logging.py} +0 -0
  61. {hud_python-0.4.11.dist-info → hud_python-0.4.12.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.11.dist-info → hud_python-0.4.12.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.11.dist-info → hud_python-0.4.12.dist-info}/licenses/LICENSE +0 -0
hud/cli/push.py CHANGED
@@ -21,6 +21,7 @@ def _get_response_text(response: requests.Response) -> str:
21
21
  except Exception:
22
22
  return response.text
23
23
 
24
+
24
25
  def get_docker_username() -> str | None:
25
26
  """Get the current Docker username if logged in."""
26
27
  try:
@@ -53,10 +54,10 @@ def get_docker_username() -> str | None:
53
54
  username = decoded.split(":", 1)[0]
54
55
  if username and username != "token": # Skip token-based auth
55
56
  return username
56
- except Exception:
57
- pass # Silent failure, try other methods
58
- except Exception:
59
- pass # Silent failure, try other methods
57
+ except Exception: # noqa: S110
58
+ pass
59
+ except Exception: # noqa: S110
60
+ pass
60
61
 
61
62
  # Alternative: Check credsStore/credHelpers
62
63
  for config_path in config_paths:
@@ -91,12 +92,12 @@ def get_docker_username() -> str | None:
91
92
  username = cred_data.get("Username", "")
92
93
  if username and username != "token":
93
94
  return username
94
- except Exception:
95
- pass # Silent failure, try other methods
96
- except Exception:
97
- pass # Silent failure, try other methods
98
- except Exception:
99
- pass # Silent failure, try other methods
95
+ except Exception: # noqa: S110
96
+ pass
97
+ except Exception: # noqa: S110
98
+ pass
99
+ except Exception: # noqa: S110
100
+ pass
100
101
  return None
101
102
 
102
103
 
@@ -155,7 +156,7 @@ def push_environment(
155
156
  if not local_image and "build" in lock_data:
156
157
  # New format might have image elsewhere
157
158
  local_image = lock_data.get("image", "")
158
-
159
+
159
160
  # Get internal version from lock file
160
161
  internal_version = lock_data.get("build", {}).get("version", None)
161
162
 
@@ -206,21 +207,25 @@ def push_environment(
206
207
  # Handle tag when image is provided
207
208
  # Prefer explicit tag over internal version
208
209
  final_tag = tag if tag else internal_version
209
-
210
+
210
211
  if ":" in image:
211
212
  # Image already has a tag
212
213
  existing_tag = image.split(":")[-1]
213
214
  if existing_tag != final_tag:
214
215
  if tag:
215
- design.warning(f"Image already has tag '{existing_tag}', overriding with '{final_tag}'")
216
+ design.warning(
217
+ f"Image already has tag '{existing_tag}', overriding with '{final_tag}'"
218
+ )
216
219
  else:
217
- design.info(f"Image has tag '{existing_tag}', but using internal version '{final_tag}'")
220
+ design.info(
221
+ f"Image has tag '{existing_tag}', but using internal version '{final_tag}'"
222
+ )
218
223
  image = image.rsplit(":", 1)[0] + f":{final_tag}"
219
224
  # else: tags match, no action needed
220
225
  else:
221
226
  # Image has no tag, append the appropriate one
222
227
  image = f"{image}:{final_tag}"
223
-
228
+
224
229
  if tag:
225
230
  design.info(f"Using specified tag: {tag}")
226
231
  else:
@@ -230,7 +235,7 @@ def push_environment(
230
235
  # Verify local image exists
231
236
  # Extract the tag part (before @sha256:...) for Docker operations
232
237
  local_tag = local_image.split("@")[0] if "@" in local_image else local_image
233
-
238
+
234
239
  # Also check for version-tagged image if we have internal version
235
240
  version_tag = None
236
241
  if internal_version and ":" in local_tag:
@@ -246,7 +251,7 @@ def push_environment(
246
251
  design.info(f"Found version-tagged image: {version_tag}")
247
252
  except subprocess.CalledProcessError:
248
253
  pass
249
-
254
+
250
255
  if not image_to_push:
251
256
  try:
252
257
  subprocess.run(["docker", "inspect", local_tag], capture_output=True, check=True) # noqa: S603, S607
@@ -319,7 +324,7 @@ def push_environment(
319
324
  lock_data["image"] = pushed_digest
320
325
 
321
326
  # Add push information
322
- from datetime import datetime, UTC
327
+ from datetime import UTC, datetime
323
328
 
324
329
  lock_data["push"] = {
325
330
  "source": local_image,
@@ -340,13 +345,20 @@ def push_environment(
340
345
  # e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
341
346
  # Use the original image name for the registry path, not the digest
342
347
  # The digest might not contain the tag information
343
- registry_image = image # This is the image we tagged and pushed (e.g., hudpython/hud-text-2048:0.1.2)
344
-
348
+ registry_image = (
349
+ image # This is the image we tagged and pushed (e.g., hudpython/hud-text-2048:0.1.2)
350
+ )
351
+
345
352
  # Remove any registry prefix for the HUD registry path
346
353
  registry_parts = registry_image.split("/")
347
354
  if len(registry_parts) >= 2:
348
355
  # Handle docker.io/org/name or just org/name
349
- if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io", "ghcr.io"]:
356
+ if registry_parts[0] in [
357
+ "docker.io",
358
+ "registry-1.docker.io",
359
+ "index.docker.io",
360
+ "ghcr.io",
361
+ ]:
350
362
  # Remove registry prefix
351
363
  name_with_tag = "/".join(registry_parts[1:])
352
364
  elif "." in registry_parts[0] or ":" in registry_parts[0]:
@@ -359,12 +371,12 @@ def push_environment(
359
371
  name_with_tag = registry_image
360
372
 
361
373
  # The image variable already has the tag, no need to add :latest
362
-
374
+
363
375
  # Validate the image format
364
376
  if not name_with_tag:
365
377
  design.warning("Could not determine image name for registry upload")
366
378
  raise typer.Exit(0)
367
-
379
+
368
380
  # For HUD registry, we need org/name format
369
381
  if "/" not in name_with_tag:
370
382
  design.warning("Image name must include organization/namespace for HUD registry")
hud/cli/remove.py CHANGED
@@ -3,14 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import shutil
6
- from pathlib import Path
7
6
 
8
7
  import typer
9
- import yaml
10
8
 
11
9
  from hud.utils.design import HUDDesign
12
10
 
13
- from .registry import get_registry_dir, list_registry_entries, load_from_registry
11
+ from .utils.registry import get_registry_dir, list_registry_entries, load_from_registry
14
12
 
15
13
 
16
14
  def remove_environment(
@@ -21,18 +19,18 @@ def remove_environment(
21
19
  """Remove an environment from the local registry."""
22
20
  design = HUDDesign()
23
21
  design.header("HUD Environment Removal")
24
-
22
+
25
23
  # Find the environment to remove
26
24
  found_entry = None
27
25
  found_digest = None
28
-
26
+
29
27
  # First check if target is a digest
30
28
  for digest, lock_file in list_registry_entries():
31
29
  if digest.startswith(target):
32
30
  found_entry = lock_file
33
31
  found_digest = digest
34
32
  break
35
-
33
+
36
34
  # If not found by digest, search by name
37
35
  if not found_entry:
38
36
  for digest, lock_file in list_registry_entries():
@@ -42,28 +40,29 @@ def remove_environment(
42
40
  image = lock_data["image"]
43
41
  # Extract name and tag
44
42
  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:
43
+ if "/" in name and (target in name or name.endswith(f"/{target}")):
44
+ found_entry = lock_file
45
+ found_digest = digest
46
+ break
47
+ except Exception as e:
48
+ design.error(f"Error loading lock file: {e}")
52
49
  continue
53
-
50
+
54
51
  if not found_entry:
55
52
  design.error(f"Environment not found: {target}")
56
53
  design.info("Use 'hud list' to see available environments")
57
54
  raise typer.Exit(1)
58
-
55
+
59
56
  # Load and display environment info
60
57
  try:
58
+ if found_digest is None:
59
+ raise ValueError("Found digest is None")
61
60
  lock_data = load_from_registry(found_digest)
62
61
  if lock_data:
63
62
  image = lock_data.get("image", "unknown")
64
63
  metadata = lock_data.get("metadata", {})
65
64
  description = metadata.get("description", "No description")
66
-
65
+
67
66
  design.section_title("Environment Details")
68
67
  design.status_item("Image", image)
69
68
  design.status_item("Digest", found_digest)
@@ -72,20 +71,20 @@ def remove_environment(
72
71
  except Exception as e:
73
72
  if verbose:
74
73
  design.warning(f"Could not read environment details: {e}")
75
-
74
+
76
75
  # Confirm deletion
77
76
  if not yes:
78
77
  design.info("")
79
78
  if not typer.confirm(f"Remove environment {found_digest}?"):
80
79
  design.info("Aborted")
81
80
  raise typer.Exit(0)
82
-
81
+
83
82
  # Remove the environment directory
84
83
  try:
85
84
  env_dir = found_entry.parent
86
85
  shutil.rmtree(env_dir)
87
86
  design.success(f"Removed environment: {found_digest}")
88
-
87
+
89
88
  # Check if the image is still available locally
90
89
  if lock_data:
91
90
  image = lock_data.get("image", "")
@@ -95,7 +94,7 @@ def remove_environment(
95
94
  design.info(f"To remove it, run: [cyan]docker rmi {image.split('@')[0]}[/cyan]")
96
95
  except Exception as e:
97
96
  design.error(f"Failed to remove environment: {e}")
98
- raise typer.Exit(1)
97
+ raise typer.Exit(1) from e
99
98
 
100
99
 
101
100
  def remove_all_environments(
@@ -105,23 +104,23 @@ def remove_all_environments(
105
104
  """Remove all environments from the local registry."""
106
105
  design = HUDDesign()
107
106
  design.header("Remove All HUD Environments")
108
-
107
+
109
108
  registry_dir = get_registry_dir()
110
109
  if not registry_dir.exists():
111
110
  design.info("No environments found in local registry.")
112
111
  return
113
-
112
+
114
113
  # Count environments
115
114
  entries = list(list_registry_entries())
116
115
  if not entries:
117
116
  design.info("No environments found in local registry.")
118
117
  return
119
-
118
+
120
119
  design.warning(f"This will remove {len(entries)} environment(s) from the local registry!")
121
-
120
+
122
121
  # List environments that will be removed
123
122
  design.section_title("Environments to Remove")
124
- for digest, lock_file in entries:
123
+ for digest, _ in entries:
125
124
  try:
126
125
  lock_data = load_from_registry(digest)
127
126
  if lock_data:
@@ -129,18 +128,18 @@ def remove_all_environments(
129
128
  design.info(f" • {digest[:12]} - {image}")
130
129
  except Exception:
131
130
  design.info(f" • {digest[:12]}")
132
-
131
+
133
132
  # Confirm deletion
134
133
  if not yes:
135
134
  design.info("")
136
135
  if not typer.confirm("Remove ALL environments?", default=False):
137
136
  design.info("Aborted")
138
137
  raise typer.Exit(0)
139
-
138
+
140
139
  # Remove all environments
141
140
  removed = 0
142
141
  failed = 0
143
-
142
+
144
143
  for digest, lock_file in entries:
145
144
  try:
146
145
  env_dir = lock_file.parent
@@ -152,13 +151,13 @@ def remove_all_environments(
152
151
  failed += 1
153
152
  if verbose:
154
153
  design.error(f"Failed to remove {digest}: {e}")
155
-
154
+
156
155
  design.info("")
157
156
  if failed == 0:
158
157
  design.success(f"Successfully removed {removed} environment(s)")
159
158
  else:
160
159
  design.warning(f"Removed {removed} environment(s), failed to remove {failed}")
161
-
160
+
162
161
  design.info("")
163
162
  design.info("Note: Docker images may still exist locally.")
164
163
  design.info("To remove them, use: [cyan]docker image prune[/cyan]")
@@ -166,21 +165,16 @@ def remove_all_environments(
166
165
 
167
166
  def remove_command(
168
167
  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"
168
+ None, help="Environment to remove (digest, name, or 'all' for all environments)"
177
169
  ),
170
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
171
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
178
172
  ) -> None:
179
173
  """🗑️ Remove HUD environments from local registry.
180
-
174
+
181
175
  Removes environment metadata from ~/.hud/envs/
182
176
  Note: This does not remove the Docker images.
183
-
177
+
184
178
  Examples:
185
179
  hud remove abc123 # Remove by digest
186
180
  hud remove text_2048 # Remove by name
@@ -193,7 +187,7 @@ def remove_command(
193
187
  design.error("Please specify an environment to remove or 'all'")
194
188
  design.info("Use 'hud list' to see available environments")
195
189
  raise typer.Exit(1)
196
-
190
+
197
191
  if target.lower() == "all":
198
192
  remove_all_environments(yes, verbose)
199
193
  else:
@@ -82,6 +82,7 @@ class TestAnalyzeEnvironment:
82
82
  with (
83
83
  patch("hud.cli.analyze.MCPClient") as MockClient,
84
84
  patch("hud.cli.analyze.console") as mock_console,
85
+ patch("platform.system", return_value="Windows"),
85
86
  ):
86
87
  # Setup mock client that will raise exception during initialization
87
88
  mock_client = MagicMock()
@@ -100,7 +101,7 @@ class TestAnalyzeEnvironment:
100
101
  mock_client.initialize.assert_called_once()
101
102
  mock_client.shutdown.assert_called_once()
102
103
 
103
- # Check console printed error hints
104
+ # Check console printed Windows-specific error hints
104
105
  calls = mock_console.print.call_args_list
105
106
  assert any("Docker logs may not show on Windows" in str(call) for call in calls)
106
107
 
@@ -3,20 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import os
7
- import tempfile
8
- from pathlib import Path
9
6
  from unittest import mock
10
7
 
11
8
  import pytest
12
9
  import yaml
13
10
 
14
- from hud.cli.analyze_metadata import (
11
+ from hud.cli.utils.metadata import (
15
12
  analyze_from_metadata,
16
13
  check_local_cache,
17
14
  fetch_lock_from_registry,
18
15
  )
19
- from hud.cli.registry import save_to_registry
16
+ from hud.cli.utils.registry import save_to_registry
20
17
 
21
18
 
22
19
  @pytest.fixture
@@ -79,9 +76,7 @@ class TestFetchLockFromRegistry:
79
76
  """Test successful fetch from registry."""
80
77
  mock_response = mock.Mock()
81
78
  mock_response.status_code = 200
82
- mock_response.json.return_value = {
83
- "lock": yaml.dump({"test": "data"})
84
- }
79
+ mock_response.json.return_value = {"lock": yaml.dump({"test": "data"})}
85
80
  mock_get.return_value = mock_response
86
81
 
87
82
  result = fetch_lock_from_registry("test/env:latest")
@@ -93,9 +88,7 @@ class TestFetchLockFromRegistry:
93
88
  """Test fetch when response has lock_data key."""
94
89
  mock_response = mock.Mock()
95
90
  mock_response.status_code = 200
96
- mock_response.json.return_value = {
97
- "lock_data": {"test": "data"}
98
- }
91
+ mock_response.json.return_value = {"lock_data": {"test": "data"}}
99
92
  mock_get.return_value = mock_response
100
93
 
101
94
  result = fetch_lock_from_registry("test/env:latest")
@@ -120,10 +113,10 @@ class TestFetchLockFromRegistry:
120
113
  mock_get.return_value = mock_response
121
114
 
122
115
  fetch_lock_from_registry("test/env")
123
-
124
- # Check that the URL includes :latest
116
+
117
+ # Check that the URL includes :latest (URL-encoded)
125
118
  call_args = mock_get.call_args
126
- assert "test/env:latest" in call_args[0][0]
119
+ assert "test/env%3Alatest" in call_args[0][0]
127
120
 
128
121
  @mock.patch("requests.get")
129
122
  def test_fetch_lock_failure(self, mock_get):
@@ -150,11 +143,11 @@ class TestCheckLocalCache:
150
143
  def test_check_local_cache_found(self, mock_registry_dir, sample_lock_data, monkeypatch):
151
144
  """Test finding lock data in local cache."""
152
145
  # Mock registry directory
153
- monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
154
-
146
+ monkeypatch.setattr("hud.cli.utils.registry.get_registry_dir", lambda: mock_registry_dir)
147
+
155
148
  # Save sample data to registry
156
149
  save_to_registry(sample_lock_data, "test/environment:latest", verbose=False)
157
-
150
+
158
151
  # Check cache
159
152
  result = check_local_cache("test/environment:latest")
160
153
  assert result is not None
@@ -162,21 +155,21 @@ class TestCheckLocalCache:
162
155
 
163
156
  def test_check_local_cache_not_found(self, mock_registry_dir, monkeypatch):
164
157
  """Test when lock data not in local cache."""
165
- monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
166
-
158
+ monkeypatch.setattr("hud.cli.utils.registry.get_registry_dir", lambda: mock_registry_dir)
159
+
167
160
  result = check_local_cache("nonexistent/env:latest")
168
161
  assert result is None
169
162
 
170
163
  def test_check_local_cache_invalid_yaml(self, mock_registry_dir, monkeypatch):
171
164
  """Test when lock file has invalid YAML."""
172
- monkeypatch.setattr("hud.cli.registry.get_registry_dir", lambda: mock_registry_dir)
173
-
165
+ monkeypatch.setattr("hud.cli.utils.registry.get_registry_dir", lambda: mock_registry_dir)
166
+
174
167
  # Create invalid lock file
175
- digest = "sha256:invalid"
168
+ digest = "invalid"
176
169
  lock_file = mock_registry_dir / digest / "hud.lock.yaml"
177
170
  lock_file.parent.mkdir(parents=True)
178
171
  lock_file.write_text("invalid: yaml: content:")
179
-
172
+
180
173
  result = check_local_cache("test/invalid:latest")
181
174
  assert result is None
182
175
 
@@ -189,60 +182,60 @@ class TestCheckLocalCache:
189
182
  class TestAnalyzeFromMetadata:
190
183
  """Test the main analyze_from_metadata function."""
191
184
 
192
- @mock.patch("hud.cli.analyze_metadata.check_local_cache")
193
- @mock.patch("hud.cli.analyze_metadata.console")
185
+ @mock.patch("hud.cli.utils.metadata.check_local_cache")
186
+ @mock.patch("hud.cli.utils.metadata.console")
194
187
  async def test_analyze_from_local_cache(self, mock_console, mock_check, sample_lock_data):
195
188
  """Test analyzing from local cache."""
196
189
  mock_check.return_value = sample_lock_data
197
-
190
+
198
191
  await analyze_from_metadata("test/env:latest", "json", verbose=False)
199
-
192
+
200
193
  mock_check.assert_called_once_with("test/env:latest")
201
194
  # Should output JSON
202
195
  mock_console.print_json.assert_called_once()
203
196
 
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")
197
+ @mock.patch("hud.cli.utils.metadata.check_local_cache")
198
+ @mock.patch("hud.cli.utils.metadata.fetch_lock_from_registry")
199
+ @mock.patch("hud.cli.utils.registry.save_to_registry")
200
+ @mock.patch("hud.cli.utils.metadata.console")
208
201
  async def test_analyze_from_registry(
209
202
  self, mock_console, mock_save, mock_fetch, mock_check, sample_lock_data
210
203
  ):
211
204
  """Test analyzing from registry when not in cache."""
212
205
  mock_check.return_value = None
213
206
  mock_fetch.return_value = sample_lock_data
214
-
207
+
215
208
  await analyze_from_metadata("test/env:latest", "json", verbose=False)
216
-
209
+
217
210
  mock_check.assert_called_once()
218
211
  mock_fetch.assert_called_once()
219
212
  mock_save.assert_called_once() # Should save to cache
220
213
  mock_console.print_json.assert_called_once()
221
214
 
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")
215
+ @mock.patch("hud.cli.utils.metadata.check_local_cache")
216
+ @mock.patch("hud.cli.utils.metadata.fetch_lock_from_registry")
217
+ @mock.patch("hud.cli.utils.metadata.design")
218
+ @mock.patch("hud.cli.utils.metadata.console")
226
219
  async def test_analyze_not_found(self, mock_console, mock_design, mock_fetch, mock_check):
227
220
  """Test when environment not found anywhere."""
228
221
  mock_check.return_value = None
229
222
  mock_fetch.return_value = None
230
-
223
+
231
224
  await analyze_from_metadata("test/notfound:latest", "json", verbose=False)
232
-
225
+
233
226
  # Should show error
234
227
  mock_design.error.assert_called_with("Environment metadata not found")
235
228
  # Should print suggestions
236
229
  mock_console.print.assert_called()
237
230
 
238
- @mock.patch("hud.cli.analyze_metadata.check_local_cache")
239
- @mock.patch("hud.cli.analyze_metadata.console")
231
+ @mock.patch("hud.cli.utils.metadata.check_local_cache")
232
+ @mock.patch("hud.cli.utils.metadata.console")
240
233
  async def test_analyze_verbose_mode(self, mock_console, mock_check, sample_lock_data):
241
234
  """Test verbose mode includes input schemas."""
242
235
  mock_check.return_value = sample_lock_data
243
-
236
+
244
237
  await analyze_from_metadata("test/env:latest", "json", verbose=True)
245
-
238
+
246
239
  # In verbose mode, the JSON output should include input schemas
247
240
  mock_console.print_json.assert_called_once()
248
241
  # Get the JSON string that was printed
@@ -250,13 +243,13 @@ class TestAnalyzeFromMetadata:
250
243
  output_data = json.loads(call_args)
251
244
  assert "inputSchema" in output_data["tools"][0]
252
245
 
253
- @mock.patch("hud.cli.analyze_metadata.check_local_cache")
254
- @mock.patch("hud.cli.analyze_metadata.fetch_lock_from_registry")
246
+ @mock.patch("hud.cli.utils.metadata.check_local_cache")
247
+ @mock.patch("hud.cli.utils.metadata.fetch_lock_from_registry")
255
248
  async def test_analyze_registry_reference_parsing(self, mock_fetch, mock_check):
256
249
  """Test parsing of different registry reference formats."""
257
250
  mock_check.return_value = None
258
251
  mock_fetch.return_value = {"test": "data"}
259
-
252
+
260
253
  # Test different reference formats
261
254
  test_cases = [
262
255
  ("docker.io/org/name:tag", "org/name:tag"),
@@ -265,13 +258,13 @@ class TestAnalyzeFromMetadata:
265
258
  ("org/name", "org/name"),
266
259
  ("name:tag", "name:tag"),
267
260
  ]
268
-
261
+
269
262
  for input_ref, expected_call in test_cases:
270
263
  await analyze_from_metadata(input_ref, "json", verbose=False)
271
-
264
+
272
265
  # Check what was passed to fetch_lock_from_registry
273
266
  calls = mock_fetch.call_args_list
274
267
  last_call = calls[-1][0][0]
275
-
268
+
276
269
  # The function might add :latest, so check base name
277
270
  assert expected_call.split(":")[0] in last_call