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.
- hud/agents/base.py +9 -2
- hud/agents/openai_chat_generic.py +15 -3
- hud/agents/tests/test_base.py +15 -0
- hud/agents/tests/test_base_runtime.py +164 -0
- hud/cli/__init__.py +20 -12
- hud/cli/build.py +35 -27
- hud/cli/dev.py +13 -31
- hud/cli/eval.py +85 -84
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +24 -2
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +134 -0
- hud/cli/tests/test_eval.py +6 -6
- hud/cli/tests/test_mcp_server.py +8 -7
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/utils/docker.py +120 -1
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +2 -2
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_runner.py +106 -0
- hud/datasets/tests/test_utils.py +228 -0
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_instrumentation.py +207 -0
- hud/server/tests/test_server_extra.py +2 -0
- hud/shared/exceptions.py +35 -4
- hud/shared/hints.py +25 -0
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +31 -23
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/tests/test_async_context.py +242 -0
- hud/telemetry/tests/test_instrument.py +414 -0
- hud/telemetry/tests/test_job.py +609 -0
- hud/telemetry/tests/test_trace.py +183 -5
- hud/tools/computer/settings.py +2 -2
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/types.py +17 -1
- hud/utils/agent_factories.py +1 -3
- hud/utils/mcp.py +1 -1
- hud/utils/tests/test_agent_factories.py +60 -0
- hud/utils/tests/test_mcp.py +4 -6
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tasks.py +187 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/METADATA +49 -49
- {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/RECORD +70 -32
- {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/WHEEL +0 -0
- {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/entry_points.txt +0 -0
- {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
|
|
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:
|
|
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 ==
|
|
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 ==
|
|
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
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 ==
|
|
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 ==
|
|
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:
|
|
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 ==
|
|
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 ==
|
|
279
|
+
elif agent_type == AgentType.VLLM:
|
|
259
280
|
# Special handling for vLLM
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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 ==
|
|
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 ==
|
|
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:
|
|
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
|
-
|
|
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 ==
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
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 ==
|
|
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:
|
|
541
|
-
|
|
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 =
|
|
652
|
+
agent = AgentType.INTEGRATION_TEST
|
|
652
653
|
|
|
653
654
|
# Check for required API keys
|
|
654
|
-
if agent ==
|
|
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 ==
|
|
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 ==
|
|
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
|
hud/cli/tests/test_build.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|