hud-python 0.4.52__py3-none-any.whl → 0.4.54__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 (70) hide show
  1. hud/agents/base.py +9 -2
  2. hud/agents/openai_chat_generic.py +15 -3
  3. hud/agents/tests/test_base.py +15 -0
  4. hud/agents/tests/test_base_runtime.py +164 -0
  5. hud/cli/__init__.py +20 -12
  6. hud/cli/build.py +35 -27
  7. hud/cli/dev.py +13 -31
  8. hud/cli/eval.py +85 -84
  9. hud/cli/tests/test_analyze_module.py +120 -0
  10. hud/cli/tests/test_build.py +24 -2
  11. hud/cli/tests/test_build_failure.py +41 -0
  12. hud/cli/tests/test_build_module.py +50 -0
  13. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  14. hud/cli/tests/test_cli_root.py +134 -0
  15. hud/cli/tests/test_eval.py +6 -6
  16. hud/cli/tests/test_mcp_server.py +8 -7
  17. hud/cli/tests/test_push_happy.py +74 -0
  18. hud/cli/tests/test_push_wrapper.py +23 -0
  19. hud/cli/utils/docker.py +120 -1
  20. hud/cli/utils/runner.py +1 -1
  21. hud/cli/utils/tests/__init__.py +0 -0
  22. hud/cli/utils/tests/test_config.py +58 -0
  23. hud/cli/utils/tests/test_docker.py +93 -0
  24. hud/cli/utils/tests/test_docker_hints.py +71 -0
  25. hud/cli/utils/tests/test_env_check.py +74 -0
  26. hud/cli/utils/tests/test_environment.py +42 -0
  27. hud/cli/utils/tests/test_interactive_module.py +60 -0
  28. hud/cli/utils/tests/test_local_runner.py +50 -0
  29. hud/cli/utils/tests/test_logging_utils.py +23 -0
  30. hud/cli/utils/tests/test_metadata.py +49 -0
  31. hud/cli/utils/tests/test_package_runner.py +35 -0
  32. hud/cli/utils/tests/test_registry_utils.py +49 -0
  33. hud/cli/utils/tests/test_remote_runner.py +25 -0
  34. hud/cli/utils/tests/test_runner_modules.py +52 -0
  35. hud/cli/utils/tests/test_source_hash.py +36 -0
  36. hud/cli/utils/tests/test_tasks.py +80 -0
  37. hud/cli/utils/version_check.py +2 -2
  38. hud/datasets/tests/__init__.py +0 -0
  39. hud/datasets/tests/test_runner.py +106 -0
  40. hud/datasets/tests/test_utils.py +228 -0
  41. hud/otel/tests/__init__.py +0 -1
  42. hud/otel/tests/test_instrumentation.py +207 -0
  43. hud/server/tests/test_server_extra.py +2 -0
  44. hud/shared/exceptions.py +35 -4
  45. hud/shared/hints.py +25 -0
  46. hud/shared/requests.py +15 -3
  47. hud/shared/tests/test_exceptions.py +31 -23
  48. hud/shared/tests/test_hints.py +167 -0
  49. hud/telemetry/tests/test_async_context.py +242 -0
  50. hud/telemetry/tests/test_instrument.py +414 -0
  51. hud/telemetry/tests/test_job.py +609 -0
  52. hud/telemetry/tests/test_trace.py +183 -5
  53. hud/tools/computer/settings.py +2 -2
  54. hud/tools/tests/test_submit.py +85 -0
  55. hud/tools/tests/test_types.py +193 -0
  56. hud/types.py +17 -1
  57. hud/utils/agent_factories.py +1 -3
  58. hud/utils/mcp.py +1 -1
  59. hud/utils/tests/test_agent_factories.py +60 -0
  60. hud/utils/tests/test_mcp.py +4 -6
  61. hud/utils/tests/test_pretty_errors.py +186 -0
  62. hud/utils/tests/test_tasks.py +187 -0
  63. hud/utils/tests/test_tool_shorthand.py +154 -0
  64. hud/utils/tests/test_version.py +1 -1
  65. hud/version.py +1 -1
  66. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/METADATA +49 -49
  67. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/RECORD +70 -32
  68. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/WHEEL +0 -0
  69. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/entry_points.txt +0 -0
  70. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/licenses/LICENSE +0 -0
hud/cli/eval.py CHANGED
@@ -5,13 +5,14 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import logging
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Any, Literal
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
10
  import typer
11
11
 
12
12
  import hud
13
13
  from hud.cli.utils.env_check import ensure_built, find_environment_dir
14
14
  from hud.settings import settings
15
+ from hud.types import AgentType
15
16
  from hud.utils.group_eval import display_group_statistics, run_tasks_grouped
16
17
  from hud.utils.hud_console import HUDConsole
17
18
 
@@ -68,8 +69,52 @@ def get_available_models() -> list[dict[str, str | None]]:
68
69
  return []
69
70
 
70
71
 
72
+ def _build_vllm_config(
73
+ vllm_base_url: str | None,
74
+ model: str | None,
75
+ allowed_tools: list[str] | None,
76
+ verbose: bool,
77
+ ) -> dict[str, Any]:
78
+ """Build configuration for vLLM agent.
79
+
80
+ Args:
81
+ vllm_base_url: Optional base URL for vLLM server
82
+ model: Model name to use
83
+ allowed_tools: Optional list of allowed tools
84
+ verbose: Enable verbose output
85
+
86
+ Returns:
87
+ Dictionary with agent configuration
88
+ """
89
+ # Determine base URL and API key
90
+ if vllm_base_url is not None:
91
+ base_url = vllm_base_url
92
+ api_key = settings.api_key if base_url.startswith(settings.hud_rl_url) else "token-abc123"
93
+ hud_console.info(f"Using vLLM server at {base_url}")
94
+ else:
95
+ base_url = "http://localhost:8000/v1"
96
+ api_key = "token-abc123"
97
+
98
+ config: dict[str, Any] = {
99
+ "api_key": api_key,
100
+ "base_url": base_url,
101
+ "model_name": model or "served-model",
102
+ "verbose": verbose,
103
+ "completion_kwargs": {
104
+ "temperature": 0.7,
105
+ "max_tokens": 2048,
106
+ "tool_choice": "auto",
107
+ },
108
+ }
109
+
110
+ if allowed_tools:
111
+ config["allowed_tools"] = allowed_tools
112
+
113
+ return config
114
+
115
+
71
116
  def build_agent(
72
- agent_type: Literal["claude", "openai", "vllm", "litellm", "integration_test"],
117
+ agent_type: AgentType,
73
118
  *,
74
119
  model: str | None = None,
75
120
  allowed_tools: list[str] | None = None,
@@ -79,15 +124,13 @@ def build_agent(
79
124
  """Create and return the requested agent type."""
80
125
 
81
126
  # Import agents lazily to avoid dependency issues
82
- if agent_type == "integration_test":
127
+ if agent_type == AgentType.INTEGRATION_TEST:
83
128
  from hud.agents.misc.integration_test_agent import IntegrationTestRunner
84
129
 
85
130
  return IntegrationTestRunner(verbose=verbose)
86
- elif agent_type == "vllm":
131
+ elif agent_type == AgentType.VLLM:
87
132
  # Create a generic OpenAI agent for vLLM server
88
133
  try:
89
- from openai import AsyncOpenAI
90
-
91
134
  from hud.agents.openai_chat_generic import GenericOpenAIChatAgent
92
135
  except ImportError as e:
93
136
  hud_console.error(
@@ -96,38 +139,16 @@ def build_agent(
96
139
  )
97
140
  raise typer.Exit(1) from e
98
141
 
99
- # Determine the base URL to use
100
- if vllm_base_url is not None:
101
- # Use the provided vLLM URL (for custom/local servers)
102
- base_url = vllm_base_url
103
- hud_console.info(f"Using vLLM server at {base_url}")
104
- api_key = (
105
- settings.api_key if base_url.startswith(settings.hud_rl_url) else "token-abc123"
106
- )
107
- else:
108
- # Default to localhost
109
- base_url = "http://localhost:8000/v1"
110
- api_key = "token-abc123"
111
-
112
- # Create OpenAI client for vLLM
113
- openai_client = AsyncOpenAI(
114
- base_url=base_url,
115
- api_key=api_key,
116
- timeout=30.0,
117
- )
118
-
119
- return GenericOpenAIChatAgent(
120
- openai_client=openai_client,
121
- model_name=model or "served-model", # Default model name
142
+ # Use the shared config builder
143
+ config = _build_vllm_config(
144
+ vllm_base_url=vllm_base_url,
145
+ model=model,
146
+ allowed_tools=allowed_tools,
122
147
  verbose=verbose,
123
- completion_kwargs={
124
- "temperature": 0.7,
125
- "max_tokens": 2048,
126
- "tool_choice": "required", # if self.actor_config.force_tool_choice else "auto",
127
- },
128
148
  )
149
+ return GenericOpenAIChatAgent(**config)
129
150
 
130
- elif agent_type == "openai":
151
+ elif agent_type == AgentType.OPENAI:
131
152
  try:
132
153
  from hud.agents import OperatorAgent
133
154
  except ImportError as e:
@@ -145,7 +166,7 @@ def build_agent(
145
166
  else:
146
167
  return OperatorAgent(verbose=verbose)
147
168
 
148
- elif agent_type == "litellm":
169
+ elif agent_type == AgentType.LITELLM:
149
170
  try:
150
171
  from hud.agents.lite_llm import LiteAgent
151
172
  except ImportError as e:
@@ -189,7 +210,7 @@ def build_agent(
189
210
  async def run_single_task(
190
211
  source: str,
191
212
  *,
192
- agent_type: Literal["claude", "openai", "vllm", "litellm", "integration_test"] = "claude",
213
+ agent_type: AgentType = AgentType.CLAUDE,
193
214
  model: str | None = None,
194
215
  allowed_tools: list[str] | None = None,
195
216
  max_steps: int = 10,
@@ -248,42 +269,34 @@ async def run_single_task(
248
269
 
249
270
  # Use grouped evaluation if group_size > 1
250
271
  agent_config: dict[str, Any] = {}
251
- if agent_type == "integration_test":
272
+ if agent_type == AgentType.INTEGRATION_TEST:
252
273
  from hud.agents.misc.integration_test_agent import IntegrationTestRunner
253
274
 
254
275
  agent_class = IntegrationTestRunner
255
276
  agent_config = {"verbose": verbose}
256
277
  if allowed_tools:
257
278
  agent_config["allowed_tools"] = allowed_tools
258
- elif agent_type == "vllm":
279
+ elif agent_type == AgentType.VLLM:
259
280
  # Special handling for vLLM
260
- sample_agent = build_agent(
261
- agent_type,
281
+ from hud.agents.openai_chat_generic import GenericOpenAIChatAgent
282
+
283
+ agent_class = GenericOpenAIChatAgent
284
+
285
+ # Use the shared config builder
286
+ agent_config = _build_vllm_config(
287
+ vllm_base_url=vllm_base_url,
262
288
  model=model,
263
289
  allowed_tools=allowed_tools,
264
290
  verbose=verbose,
265
- vllm_base_url=vllm_base_url,
266
291
  )
267
- agent_config = {
268
- "openai_client": sample_agent.oai,
269
- "model_name": sample_agent.model_name,
270
- "verbose": verbose,
271
- "completion_kwargs": sample_agent.completion_kwargs,
272
- }
273
- if allowed_tools:
274
- agent_config["allowed_tools"] = allowed_tools
275
-
276
- from hud.agents.openai_chat_generic import GenericOpenAIChatAgent
277
-
278
- agent_class = GenericOpenAIChatAgent
279
- elif agent_type == "openai":
292
+ elif agent_type == AgentType.OPENAI:
280
293
  from hud.agents import OperatorAgent
281
294
 
282
295
  agent_class = OperatorAgent
283
296
  agent_config = {"verbose": verbose}
284
297
  if allowed_tools:
285
298
  agent_config["allowed_tools"] = allowed_tools
286
- elif agent_type == "litellm":
299
+ elif agent_type == AgentType.LITELLM:
287
300
  from hud.agents.lite_llm import LiteAgent
288
301
 
289
302
  agent_class = LiteAgent
@@ -293,7 +306,7 @@ async def run_single_task(
293
306
  }
294
307
  if allowed_tools:
295
308
  agent_config["allowed_tools"] = allowed_tools
296
- elif agent_type == "claude":
309
+ elif agent_type == AgentType.CLAUDE:
297
310
  from hud.agents import ClaudeAgent
298
311
 
299
312
  agent_class = ClaudeAgent
@@ -341,7 +354,7 @@ async def run_single_task(
341
354
  async def run_full_dataset(
342
355
  source: str,
343
356
  *,
344
- agent_type: Literal["claude", "openai", "vllm", "litellm", "integration_test"] = "claude",
357
+ agent_type: AgentType = AgentType.CLAUDE,
345
358
  model: str | None = None,
346
359
  allowed_tools: list[str] | None = None,
347
360
  max_concurrent: int = 30,
@@ -382,12 +395,13 @@ async def run_full_dataset(
382
395
  dataset_name = f"Dataset: {path.name}" if path.exists() else source.split("/")[-1]
383
396
 
384
397
  # Build agent class + config for run_dataset
385
- if agent_type == "integration_test": # --integration-test mode
398
+ agent_config: dict[str, Any]
399
+ if agent_type == AgentType.INTEGRATION_TEST: # --integration-test mode
386
400
  from hud.agents.misc.integration_test_agent import IntegrationTestRunner
387
401
 
388
402
  agent_class = IntegrationTestRunner
389
403
  agent_config = {"verbose": verbose}
390
- elif agent_type == "vllm":
404
+ elif agent_type == AgentType.VLLM:
391
405
  try:
392
406
  from hud.agents.openai_chat_generic import GenericOpenAIChatAgent
393
407
 
@@ -399,25 +413,14 @@ async def run_full_dataset(
399
413
  )
400
414
  raise typer.Exit(1) from e
401
415
 
402
- # Use build_agent to create a sample agent to get the config
403
- sample_agent = build_agent(
404
- agent_type,
416
+ # Use the shared config builder
417
+ agent_config = _build_vllm_config(
418
+ vllm_base_url=vllm_base_url,
405
419
  model=model,
406
420
  allowed_tools=allowed_tools,
407
421
  verbose=verbose,
408
- vllm_base_url=vllm_base_url,
409
422
  )
410
-
411
- # Extract the config from the sample agent
412
- agent_config: dict[str, Any] = {
413
- "openai_client": sample_agent.oai,
414
- "model_name": sample_agent.model_name,
415
- "verbose": verbose,
416
- "completion_kwargs": sample_agent.completion_kwargs,
417
- }
418
- if allowed_tools:
419
- agent_config["allowed_tools"] = allowed_tools
420
- elif agent_type == "openai":
423
+ elif agent_type == AgentType.OPENAI:
421
424
  try:
422
425
  from hud.agents import OperatorAgent
423
426
 
@@ -433,7 +436,7 @@ async def run_full_dataset(
433
436
  if allowed_tools:
434
437
  agent_config["allowed_tools"] = allowed_tools
435
438
 
436
- elif agent_type == "litellm":
439
+ elif agent_type == AgentType.LITELLM:
437
440
  try:
438
441
  from hud.agents.lite_llm import LiteAgent
439
442
 
@@ -537,8 +540,8 @@ def eval_command(
537
540
  "--full",
538
541
  help="Run the entire dataset (omit for single-task debug mode)",
539
542
  ),
540
- agent: Literal["claude", "openai", "vllm", "litellm", "integration_test"] = typer.Option(
541
- "claude",
543
+ agent: AgentType = typer.Option( # noqa: B008
544
+ AgentType.CLAUDE,
542
545
  "--agent",
543
546
  help="Agent backend to use (claude, openai, vllm for local server, or litellm)",
544
547
  ),
@@ -630,8 +633,6 @@ def eval_command(
630
633
  # Run with verbose output for debugging
631
634
  hud eval task.json --verbose
632
635
  """
633
- from hud.settings import settings
634
-
635
636
  # Always configure basic logging so agent steps can be logged
636
637
  # Set to INFO by default for consistency with run_evaluation.py
637
638
  if very_verbose:
@@ -648,21 +649,21 @@ def eval_command(
648
649
 
649
650
  # We pass integration_test as the agent_type
650
651
  if integration_test:
651
- agent = "integration_test"
652
+ agent = AgentType.INTEGRATION_TEST
652
653
 
653
654
  # Check for required API keys
654
- if agent == "claude":
655
+ if agent == AgentType.CLAUDE:
655
656
  if not settings.anthropic_api_key:
656
657
  hud_console.error("ANTHROPIC_API_KEY is required for Claude agent")
657
658
  hud_console.info(
658
659
  "Set it in your environment or run: hud set ANTHROPIC_API_KEY=your-key-here"
659
660
  )
660
661
  raise typer.Exit(1)
661
- elif agent == "openai" and not settings.openai_api_key:
662
+ elif agent == AgentType.OPENAI and not settings.openai_api_key:
662
663
  hud_console.error("OPENAI_API_KEY is required for OpenAI agent")
663
664
  hud_console.info("Set it in your environment or run: hud set OPENAI_API_KEY=your-key-here")
664
665
  raise typer.Exit(1)
665
- elif agent == "vllm":
666
+ elif agent == AgentType.VLLM:
666
667
  if model:
667
668
  hud_console.info(f"Using vLLM with model: {model}")
668
669
  else:
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.analyze import (
9
+ analyze_environment,
10
+ analyze_environment_from_config,
11
+ analyze_environment_from_mcp_config,
12
+ display_interactive,
13
+ display_markdown,
14
+ parse_docker_command,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ # Mark entire module as asyncio to ensure async tests run with pytest-asyncio
22
+ pytestmark = pytest.mark.asyncio
23
+
24
+
25
+ def test_parse_docker_command():
26
+ cmd = ["docker", "run", "--rm", "-i", "img"]
27
+ cfg = parse_docker_command(cmd)
28
+ assert cfg == {"local": {"command": "docker", "args": ["run", "--rm", "-i", "img"]}}
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ @patch("hud.cli.analyze.MCPClient")
33
+ @patch("hud.cli.analyze.console")
34
+ async def test_analyze_environment_success_json(mock_console, MockClient):
35
+ client = AsyncMock()
36
+ client.initialize.return_value = None
37
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
38
+ client.shutdown.return_value = None
39
+ MockClient.return_value = client
40
+
41
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=False)
42
+ assert client.initialize.awaited
43
+ assert client.analyze_environment.awaited
44
+ assert client.shutdown.awaited
45
+ assert mock_console.print_json.called
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ @patch("hud.cli.analyze.MCPClient")
50
+ @patch("hud.cli.analyze.console")
51
+ async def test_analyze_environment_failure(mock_console, MockClient):
52
+ client = AsyncMock()
53
+ client.initialize.side_effect = RuntimeError("boom")
54
+ client.shutdown.return_value = None
55
+ MockClient.return_value = client
56
+
57
+ # Should swallow exception and return without raising
58
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=True)
59
+ assert client.shutdown.awaited
60
+ assert mock_console.print_json.called is False
61
+
62
+
63
+ def test_display_interactive_metadata_only(monkeypatch):
64
+ import hud.cli.analyze as mod
65
+
66
+ monkeypatch.setattr(mod, "console", MagicMock(), raising=False)
67
+ monkeypatch.setattr(mod, "hud_console", MagicMock(), raising=False)
68
+
69
+ analysis = {
70
+ "image": "img:latest",
71
+ "status": "cached",
72
+ "tool_count": 2,
73
+ "tools": [
74
+ {"name": "t1", "description": "d1", "inputSchema": {"type": "object"}},
75
+ {"name": "t2", "description": "d2"},
76
+ ],
77
+ "resources": [],
78
+ }
79
+ display_interactive(analysis)
80
+
81
+
82
+ def test_display_markdown_both_paths(capsys):
83
+ # metadata-only
84
+ md_only = {"image": "img:latest", "tool_count": 0, "tools": [], "resources": []}
85
+ display_markdown(md_only)
86
+
87
+ # live metadata
88
+ live = {"metadata": {"servers": ["s1"], "initialized": True}, "tools": [], "resources": []}
89
+ display_markdown(live)
90
+
91
+ # Check that output was generated
92
+ captured = capsys.readouterr()
93
+ assert "MCP Environment Analysis" in captured.out
94
+
95
+
96
+ @patch("hud.cli.analyze.MCPClient")
97
+ async def test_analyze_environment_from_config(MockClient, tmp_path: Path):
98
+ client = AsyncMock()
99
+ client.initialize.return_value = None
100
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
101
+ client.shutdown.return_value = None
102
+ MockClient.return_value = client
103
+
104
+ cfg = tmp_path / "mcp.json"
105
+ cfg.write_text('{"local": {"command": "docker", "args": ["run", "img"]}}')
106
+ await analyze_environment_from_config(cfg, output_format="json", verbose=False)
107
+ assert client.initialize.awaited and client.shutdown.awaited
108
+
109
+
110
+ @patch("hud.cli.analyze.MCPClient")
111
+ async def test_analyze_environment_from_mcp_config(MockClient):
112
+ client = AsyncMock()
113
+ client.initialize.return_value = None
114
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
115
+ client.shutdown.return_value = None
116
+ MockClient.return_value = client
117
+
118
+ mcp_config = {"local": {"command": "docker", "args": ["run", "img"]}}
119
+ await analyze_environment_from_mcp_config(mcp_config, output_format="json", verbose=False)
120
+ assert client.initialize.awaited and client.shutdown.awaited
@@ -219,6 +219,17 @@ class TestAnalyzeMcpEnvironment:
219
219
  mock_tool.description = "Test tool"
220
220
  mock_tool.inputSchema = {"type": "object"}
221
221
 
222
+ # Prefer analyze_environment path (aligns with analyze CLI tests)
223
+ mock_client.analyze_environment = mock.AsyncMock(
224
+ return_value={
225
+ "metadata": {"servers": ["local"], "initialized": True},
226
+ "tools": [{"name": "test_tool", "description": "Test tool"}],
227
+ "hub_tools": {},
228
+ "resources": [],
229
+ "telemetry": {},
230
+ }
231
+ )
232
+ # Fallback still defined for completeness
222
233
  mock_client.list_tools.return_value = [mock_tool]
223
234
 
224
235
  result = await analyze_mcp_environment("test:latest")
@@ -237,7 +248,9 @@ class TestAnalyzeMcpEnvironment:
237
248
  mock_client_class.return_value = mock_client
238
249
  mock_client.initialize.side_effect = ConnectionError("Connection failed")
239
250
 
240
- with pytest.raises(ConnectionError):
251
+ from hud.shared.exceptions import HudException
252
+
253
+ with pytest.raises(HudException, match="Connection failed"):
241
254
  await analyze_mcp_environment("test:latest")
242
255
 
243
256
  @mock.patch("hud.cli.build.MCPClient")
@@ -245,6 +258,15 @@ class TestAnalyzeMcpEnvironment:
245
258
  """Test analysis in verbose mode."""
246
259
  mock_client = mock.AsyncMock()
247
260
  mock_client_class.return_value = mock_client
261
+ mock_client.analyze_environment = mock.AsyncMock(
262
+ return_value={
263
+ "metadata": {"servers": ["local"], "initialized": True},
264
+ "tools": [],
265
+ "hub_tools": {},
266
+ "resources": [],
267
+ "telemetry": {},
268
+ }
269
+ )
248
270
  mock_client.list_tools.return_value = []
249
271
 
250
272
  # Just test that it runs without error in verbose mode
@@ -363,7 +385,7 @@ ENV API_KEY
363
385
  mock_run.return_value = mock_result
364
386
 
365
387
  # Run build
366
- build_environment(str(env_dir), "test/env:latest")
388
+ build_environment(str(env_dir), "test-env:latest")
367
389
 
368
390
  # Check lock file was created
369
391
  lock_file = env_dir / "hud.lock.yaml"
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ import typer
8
+
9
+ from hud.cli.build import build_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.build.compute_source_hash", return_value="deadbeef")
16
+ @patch(
17
+ "hud.cli.build.analyze_mcp_environment",
18
+ return_value={"initializeMs": 10, "toolCount": 0, "tools": []},
19
+ )
20
+ @patch("hud.cli.build.build_docker_image", return_value=True)
21
+ def test_build_label_rebuild_failure(_bd, _an, _hash, tmp_path: Path, monkeypatch):
22
+ # Minimal environment dir
23
+ env = tmp_path / "env"
24
+ env.mkdir()
25
+ (env / "Dockerfile").write_text("FROM python:3.11")
26
+
27
+ # Ensure subprocess.run returns non-zero for the second build (label build)
28
+ import types
29
+
30
+ def run_side_effect(cmd, *a, **k):
31
+ # Return 0 for first docker build, 1 for label build
32
+ if isinstance(cmd, list) and cmd[:2] == ["docker", "build"] and "--label" in cmd:
33
+ return types.SimpleNamespace(returncode=1, stderr="boom")
34
+ return types.SimpleNamespace(returncode=0, stdout="")
35
+
36
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
37
+ with (
38
+ patch("hud.cli.build.subprocess.run", side_effect=run_side_effect),
39
+ pytest.raises(typer.Exit),
40
+ ):
41
+ build_environment(str(env), verbose=False)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest import mock
5
+
6
+ from hud.cli.build import (
7
+ extract_env_vars_from_dockerfile,
8
+ get_docker_image_digest,
9
+ get_docker_image_id,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+
16
+ def test_extract_env_vars_from_dockerfile_complex(tmp_path: Path):
17
+ dockerfile = tmp_path / "Dockerfile"
18
+ dockerfile.write_text(
19
+ """
20
+ FROM python:3.11
21
+ ARG BUILD_TOKEN
22
+ ARG DEFAULTED=1
23
+ ENV RUNTIME_KEY
24
+ ENV FROM_ARG=$BUILD_TOKEN
25
+ ENV WITH_DEFAULT=val
26
+ """
27
+ )
28
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
29
+ # BUILD_TOKEN required (ARG without default)
30
+ assert "BUILD_TOKEN" in required
31
+ # RUNTIME_KEY required (ENV without value)
32
+ assert "RUNTIME_KEY" in required
33
+ # FROM_ARG references BUILD_TOKEN -> required
34
+ assert "FROM_ARG" in required
35
+ # DEFAULTED and WITH_DEFAULT should not be marked required by default
36
+ assert "DEFAULTED" not in required
37
+ assert "WITH_DEFAULT" not in required
38
+ assert optional == []
39
+
40
+
41
+ @mock.patch("subprocess.run")
42
+ def test_get_docker_image_digest_none(mock_run):
43
+ mock_run.return_value = mock.Mock(stdout="[]", returncode=0)
44
+ assert get_docker_image_digest("img") is None
45
+
46
+
47
+ @mock.patch("subprocess.run")
48
+ def test_get_docker_image_id_ok(mock_run):
49
+ mock_run.return_value = mock.Mock(stdout="sha256:abc", returncode=0)
50
+ assert get_docker_image_id("img") == "sha256:abc"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ import hud.cli as cli
6
+
7
+
8
+ def test_version_does_not_crash():
9
+ # Just ensure it runs without raising
10
+ cli.version()
11
+
12
+
13
+ @patch("hud.cli.list_module.list_command")
14
+ def test_list_environments_wrapper(mock_list):
15
+ cli.list_environments(filter_name=None, json_output=False, show_all=False, verbose=False)
16
+ assert mock_list.called
17
+
18
+
19
+ @patch("hud.cli.clone_repository", return_value=(True, "/tmp/repo"))
20
+ @patch("hud.cli.get_clone_message", return_value={})
21
+ @patch("hud.cli.print_tutorial")
22
+ def test_clone_wrapper(mock_tutorial, _msg, _clone):
23
+ cli.clone("https://example.com/repo.git")
24
+ assert mock_tutorial.called
25
+
26
+
27
+ @patch("hud.cli.remove_command")
28
+ def test_remove_wrapper(mock_remove):
29
+ cli.remove(target="all", yes=True, verbose=False)
30
+ assert mock_remove.called