hud-python 0.4.52__py3-none-any.whl → 0.4.53__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 (69) 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 +6 -3
  6. hud/cli/build.py +35 -27
  7. hud/cli/dev.py +11 -29
  8. hud/cli/eval.py +61 -61
  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_mcp_server.py +8 -7
  16. hud/cli/tests/test_push_happy.py +74 -0
  17. hud/cli/tests/test_push_wrapper.py +23 -0
  18. hud/cli/utils/docker.py +120 -1
  19. hud/cli/utils/runner.py +1 -1
  20. hud/cli/utils/tests/__init__.py +0 -0
  21. hud/cli/utils/tests/test_config.py +58 -0
  22. hud/cli/utils/tests/test_docker.py +93 -0
  23. hud/cli/utils/tests/test_docker_hints.py +71 -0
  24. hud/cli/utils/tests/test_env_check.py +74 -0
  25. hud/cli/utils/tests/test_environment.py +42 -0
  26. hud/cli/utils/tests/test_interactive_module.py +60 -0
  27. hud/cli/utils/tests/test_local_runner.py +50 -0
  28. hud/cli/utils/tests/test_logging_utils.py +23 -0
  29. hud/cli/utils/tests/test_metadata.py +49 -0
  30. hud/cli/utils/tests/test_package_runner.py +35 -0
  31. hud/cli/utils/tests/test_registry_utils.py +49 -0
  32. hud/cli/utils/tests/test_remote_runner.py +25 -0
  33. hud/cli/utils/tests/test_runner_modules.py +52 -0
  34. hud/cli/utils/tests/test_source_hash.py +36 -0
  35. hud/cli/utils/tests/test_tasks.py +80 -0
  36. hud/cli/utils/version_check.py +2 -2
  37. hud/datasets/tests/__init__.py +0 -0
  38. hud/datasets/tests/test_runner.py +106 -0
  39. hud/datasets/tests/test_utils.py +228 -0
  40. hud/otel/tests/__init__.py +0 -1
  41. hud/otel/tests/test_instrumentation.py +207 -0
  42. hud/server/tests/test_server_extra.py +2 -0
  43. hud/shared/exceptions.py +35 -4
  44. hud/shared/hints.py +25 -0
  45. hud/shared/requests.py +15 -3
  46. hud/shared/tests/test_exceptions.py +31 -23
  47. hud/shared/tests/test_hints.py +167 -0
  48. hud/telemetry/tests/test_async_context.py +242 -0
  49. hud/telemetry/tests/test_instrument.py +414 -0
  50. hud/telemetry/tests/test_job.py +609 -0
  51. hud/telemetry/tests/test_trace.py +183 -5
  52. hud/tools/computer/settings.py +2 -2
  53. hud/tools/tests/test_submit.py +85 -0
  54. hud/tools/tests/test_types.py +193 -0
  55. hud/types.py +7 -1
  56. hud/utils/agent_factories.py +1 -3
  57. hud/utils/mcp.py +1 -1
  58. hud/utils/tests/test_agent_factories.py +60 -0
  59. hud/utils/tests/test_mcp.py +4 -6
  60. hud/utils/tests/test_pretty_errors.py +186 -0
  61. hud/utils/tests/test_tasks.py +187 -0
  62. hud/utils/tests/test_tool_shorthand.py +154 -0
  63. hud/utils/tests/test_version.py +1 -1
  64. hud/version.py +1 -1
  65. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/METADATA +47 -48
  66. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/RECORD +69 -31
  67. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/WHEEL +0 -0
  68. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/entry_points.txt +0 -0
  69. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/licenses/LICENSE +0 -0
hud/cli/utils/docker.py CHANGED
@@ -1,4 +1,9 @@
1
- """Docker utilities for HUD CLI."""
1
+ """Docker utilities for HUD CLI.
2
+
3
+ This module centralizes helpers for constructing Docker commands and
4
+ standardizes environment variable handling for "folder mode" (environment
5
+ directories that include a `.env` file and/or `hud.lock.yaml`).
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
@@ -6,6 +11,12 @@ import json
6
11
  import platform
7
12
  import shutil
8
13
  import subprocess
14
+ from pathlib import Path
15
+
16
+ from .config import parse_env_file
17
+
18
+ # Note: we deliberately avoid the stricter is_environment_directory() check here
19
+ # to allow folder mode with only a Dockerfile or only a pyproject.toml.
9
20
 
10
21
 
11
22
  def get_docker_cmd(image: str) -> list[str] | None:
@@ -103,6 +114,114 @@ def build_run_command(image: str, docker_args: list[str] | None = None) -> list[
103
114
  ]
104
115
 
105
116
 
117
+ def detect_environment_dir(start_dir: Path | None = None) -> Path | None:
118
+ """Detect an environment directory for folder mode.
119
+
120
+ Detection order:
121
+ - Current directory containing `hud.lock.yaml`
122
+ - Parent directory containing `hud.lock.yaml`
123
+ - Current directory that looks like an environment if it has either a
124
+ `Dockerfile` or a `pyproject.toml` (looser than `is_environment_directory`).
125
+
126
+ Returns the detected directory path or None if not found.
127
+ """
128
+ base = (start_dir or Path.cwd()).resolve()
129
+
130
+ # Check current then parent for lock file
131
+ for candidate in [base, base.parent]:
132
+ if (candidate / "hud.lock.yaml").exists():
133
+ return candidate
134
+
135
+ # Fallback: treat as env if it has Dockerfile OR pyproject.toml
136
+ if (base / "Dockerfile").exists() or (base / "pyproject.toml").exists():
137
+ return base
138
+
139
+ return None
140
+
141
+
142
+ def load_env_vars_for_dir(env_dir: Path) -> dict[str, str]:
143
+ """Load KEY=VALUE pairs from `<env_dir>/.env` if present.
144
+
145
+ Returns an empty dict if no file is found or parsing fails.
146
+ """
147
+ env_file = env_dir / ".env"
148
+ if not env_file.exists():
149
+ return {}
150
+ try:
151
+ contents = env_file.read_text(encoding="utf-8")
152
+ return parse_env_file(contents)
153
+ except Exception:
154
+ return {}
155
+
156
+
157
+ def build_env_flags(env_vars: dict[str, str]) -> list[str]:
158
+ """Convert an env dict into a flat list of `-e KEY=VALUE` flags."""
159
+ flags: list[str] = []
160
+ for key, value in env_vars.items():
161
+ flags.extend(["-e", f"{key}={value}"])
162
+ return flags
163
+
164
+
165
+ def create_docker_run_command(
166
+ image: str,
167
+ docker_args: list[str] | None = None,
168
+ env_dir: Path | str | None = None,
169
+ extra_env: dict[str, str] | None = None,
170
+ name: str | None = None,
171
+ interactive: bool = True,
172
+ remove: bool = True,
173
+ ) -> list[str]:
174
+ """Create a standardized `docker run` command with folder-mode envs.
175
+
176
+ - If `env_dir` is provided (or auto-detected), `.env` entries are injected as
177
+ `-e KEY=VALUE` flags before the image.
178
+ - `extra_env` allows callers to provide additional env pairs that override
179
+ variables from `.env`.
180
+
181
+ Args:
182
+ image: Docker image to run
183
+ docker_args: Additional docker args (volumes, ports, etc.)
184
+ env_dir: Environment directory to load `.env` from; if None, auto-detect
185
+ extra_env: Additional env variables to inject (takes precedence)
186
+ name: Optional container name
187
+ interactive: Include `-i` flag (default True)
188
+ remove: Include `--rm` flag (default True)
189
+
190
+ Returns:
191
+ Fully constructed docker run command
192
+ """
193
+ cmd: list[str] = ["docker", "run"]
194
+ if remove:
195
+ cmd.append("--rm")
196
+ if interactive:
197
+ cmd.append("-i")
198
+ if name:
199
+ cmd.extend(["--name", name])
200
+
201
+ # Load env from `.env` in detected env directory
202
+ env_dir_path: Path | None = (
203
+ Path(env_dir).resolve() if isinstance(env_dir, (str, Path)) else detect_environment_dir()
204
+ )
205
+
206
+ merged_env: dict[str, str] = {}
207
+ if env_dir_path is not None:
208
+ merged_env.update(load_env_vars_for_dir(env_dir_path))
209
+ if extra_env:
210
+ # Caller-provided values override .env
211
+ merged_env.update(extra_env)
212
+
213
+ # Insert env flags before other args
214
+ if merged_env:
215
+ cmd.extend(build_env_flags(merged_env))
216
+
217
+ # Add remaining args (volumes, ports, etc.)
218
+ if docker_args:
219
+ cmd.extend(docker_args)
220
+
221
+ cmd.append(image)
222
+ return cmd
223
+
224
+
106
225
  def _emit_docker_hints(error_text: str) -> None:
107
226
  """Parse common Docker connectivity errors and print platform-specific hints."""
108
227
  from hud.utils.hud_console import hud_console
hud/cli/utils/runner.py CHANGED
@@ -16,7 +16,7 @@ def run_stdio_server(image: str, docker_args: list[str], verbose: bool) -> None:
16
16
  """Run Docker image as stdio MCP server (direct passthrough)."""
17
17
  hud_console = HUDConsole() # Use stderr for stdio mode
18
18
 
19
- # Build docker command
19
+ # Build docker command (image-only mode: do not auto-inject local .env)
20
20
  docker_cmd = ["docker", "run", "--rm", "-i", *docker_args, image]
21
21
 
22
22
  if verbose:
File without changes
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from hud.cli.utils.config import (
6
+ ensure_config_dir,
7
+ get_config_dir,
8
+ get_user_env_path,
9
+ load_env_file,
10
+ parse_env_file,
11
+ render_env_file,
12
+ save_env_file,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+
19
+ def test_parse_env_file_basic():
20
+ contents = """
21
+ # comment
22
+ KEY=VALUE
23
+ EMPTY=
24
+ NOEQ
25
+ SPACED = v
26
+ """ # noqa: W291
27
+ data = parse_env_file(contents)
28
+ assert data["KEY"] == "VALUE"
29
+ assert data["EMPTY"] == ""
30
+ assert data["SPACED"] == "v"
31
+ assert "NOEQ" not in data
32
+
33
+
34
+ def test_render_and_load_roundtrip(tmp_path: Path):
35
+ env = {"A": "1", "B": "2"}
36
+ file_path = tmp_path / ".env"
37
+ rendered = render_env_file(env)
38
+ file_path.write_text(rendered, encoding="utf-8")
39
+ loaded = load_env_file(file_path)
40
+ assert loaded == env
41
+
42
+
43
+ def test_get_paths(monkeypatch, tmp_path: Path):
44
+ from pathlib import Path as _Path
45
+
46
+ monkeypatch.setattr(_Path, "home", lambda: tmp_path)
47
+ cfg = get_config_dir()
48
+ assert str(cfg).replace("\\", "/").endswith("/.hud")
49
+ assert str(get_user_env_path()).replace("\\", "/").endswith("/.hud/.env")
50
+
51
+
52
+ def test_ensure_and_save(tmp_path: Path, monkeypatch):
53
+ monkeypatch.setenv("HOME", str(tmp_path))
54
+ cfg = ensure_config_dir()
55
+ assert cfg.exists()
56
+ out = save_env_file({"K": "V"})
57
+ assert out.exists()
58
+ assert load_env_file(out) == {"K": "V"}
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from hud.cli.utils.docker import (
8
+ build_run_command,
9
+ generate_container_name,
10
+ get_docker_cmd,
11
+ image_exists,
12
+ remove_container,
13
+ require_docker_running,
14
+ )
15
+
16
+
17
+ def test_build_run_command_basic():
18
+ cmd = build_run_command("my-image:latest")
19
+ assert cmd[:4] == ["docker", "run", "--rm", "-i"]
20
+ assert cmd[-1] == "my-image:latest"
21
+
22
+
23
+ def test_build_run_command_with_args():
24
+ cmd = build_run_command("img", ["-e", "K=V", "-p", "8080:8080"])
25
+ assert "-e" in cmd and "K=V" in cmd
26
+ assert "-p" in cmd and "8080:8080" in cmd
27
+ assert cmd[-1] == "img"
28
+
29
+
30
+ def test_generate_container_name():
31
+ assert generate_container_name("repo/name:tag") == "hud-repo-name-tag"
32
+ assert generate_container_name("a/b:c", prefix="x") == "x-a-b-c"
33
+
34
+
35
+ @patch("subprocess.run")
36
+ def test_image_exists_true(mock_run):
37
+ mock_run.return_value = MagicMock(returncode=0)
38
+ assert image_exists("any") is True
39
+
40
+
41
+ @patch("subprocess.run")
42
+ def test_image_exists_false(mock_run):
43
+ mock_run.return_value = MagicMock(returncode=1)
44
+ assert image_exists("any") is False
45
+
46
+
47
+ @patch("subprocess.run")
48
+ def test_get_docker_cmd_success(mock_run):
49
+ mock_run.return_value = MagicMock(
50
+ stdout='[{"Config": {"Cmd": ["python", "-m", "app"]}}]', returncode=0
51
+ )
52
+ assert get_docker_cmd("img") == ["python", "-m", "app"]
53
+
54
+
55
+ @patch("subprocess.run")
56
+ def test_get_docker_cmd_none(mock_run):
57
+ mock_run.return_value = MagicMock(stdout="[]", returncode=0)
58
+ assert get_docker_cmd("img") is None
59
+
60
+
61
+ @patch("subprocess.run")
62
+ def test_remove_container_ok(mock_run):
63
+ mock_run.return_value = MagicMock(returncode=0)
64
+ assert remove_container("x") is True
65
+
66
+
67
+ @patch("shutil.which", return_value=None)
68
+ def test_require_docker_running_no_cli(_which):
69
+ import typer
70
+
71
+ with pytest.raises(typer.Exit):
72
+ require_docker_running()
73
+
74
+
75
+ @patch("shutil.which", return_value="docker")
76
+ @patch("subprocess.run")
77
+ def test_require_docker_running_ok(mock_run, _which):
78
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
79
+ require_docker_running() # should not raise
80
+
81
+
82
+ @patch("shutil.which", return_value="docker")
83
+ @patch("subprocess.run")
84
+ def test_require_docker_running_error_emits_hints(mock_run, _which):
85
+ import typer
86
+
87
+ mock_run.return_value = MagicMock(
88
+ returncode=1,
89
+ stdout="Cannot connect to the Docker daemon",
90
+ stderr="",
91
+ )
92
+ with pytest.raises(typer.Exit):
93
+ require_docker_running()
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ from hud.cli.utils import docker as mod
8
+
9
+ pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Prefers Linux")
10
+
11
+
12
+ def test_emit_docker_hints_windows(monkeypatch):
13
+ # Patch the global hud_console used by hint printing
14
+
15
+ fake = type(
16
+ "C",
17
+ (),
18
+ {
19
+ "error": lambda *a, **k: None,
20
+ "hint": lambda *a, **k: None,
21
+ "dim_info": lambda *a, **k: None,
22
+ },
23
+ )()
24
+ monkeypatch.setattr("hud.utils.hud_console.hud_console", fake, raising=False)
25
+ monkeypatch.setattr(mod.platform, "system", lambda: "Windows")
26
+ mod._emit_docker_hints("cannot connect to the docker daemon")
27
+
28
+
29
+ def test_emit_docker_hints_linux(monkeypatch):
30
+ fake = type(
31
+ "C",
32
+ (),
33
+ {
34
+ "error": lambda *a, **k: None,
35
+ "hint": lambda *a, **k: None,
36
+ "dim_info": lambda *a, **k: None,
37
+ },
38
+ )()
39
+ monkeypatch.setattr("hud.utils.hud_console.hud_console", fake, raising=False)
40
+ monkeypatch.setattr(mod.platform, "system", lambda: "Linux")
41
+ mod._emit_docker_hints("Cannot connect to the Docker daemon")
42
+
43
+
44
+ def test_emit_docker_hints_darwin(monkeypatch):
45
+ fake = type(
46
+ "C",
47
+ (),
48
+ {
49
+ "error": lambda *a, **k: None,
50
+ "hint": lambda *a, **k: None,
51
+ "dim_info": lambda *a, **k: None,
52
+ },
53
+ )()
54
+ monkeypatch.setattr("hud.utils.hud_console.hud_console", fake, raising=False)
55
+ monkeypatch.setattr(mod.platform, "system", lambda: "Darwin")
56
+ mod._emit_docker_hints("error during connect: is the docker daemon running")
57
+
58
+
59
+ def test_emit_docker_hints_generic(monkeypatch):
60
+ fake = type(
61
+ "C",
62
+ (),
63
+ {
64
+ "error": lambda *a, **k: None,
65
+ "hint": lambda *a, **k: None,
66
+ "dim_info": lambda *a, **k: None,
67
+ },
68
+ )()
69
+ monkeypatch.setattr("hud.utils.hud_console.hud_console", fake, raising=False)
70
+ monkeypatch.setattr(mod.platform, "system", lambda: "Other")
71
+ mod._emit_docker_hints("some unrelated error")
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import TYPE_CHECKING
5
+ from unittest.mock import patch
6
+
7
+ from hud.cli.utils.env_check import (
8
+ _collect_source_diffs,
9
+ _parse_generated_at,
10
+ ensure_built,
11
+ find_environment_dir,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+
18
+ def test_parse_generated_at_variants():
19
+ ts = _parse_generated_at({"build": {"generatedAt": datetime.now(UTC).isoformat()}})
20
+ assert isinstance(ts, float)
21
+ assert _parse_generated_at({}) is None
22
+
23
+
24
+ def test_collect_source_diffs_basic(tmp_path: Path):
25
+ env = tmp_path / "env"
26
+ env.mkdir()
27
+ # simulate files
28
+ (env / "Dockerfile").write_text("FROM python:3.11")
29
+ (env / "pyproject.toml").write_text("[tool.hud]")
30
+ (env / "a.txt").write_text("x")
31
+
32
+ # stored file list includes a non-existent file and old time
33
+ built_time = (datetime.now(UTC) - timedelta(days=1)).isoformat()
34
+ lock = {"build": {"sourceFiles": ["a.txt", "b.txt"], "generatedAt": built_time}}
35
+
36
+ # Patch list_source_files to return current env files
37
+ with patch("hud.cli.utils.env_check.list_source_files") as mock_list:
38
+ mock_list.return_value = [env / "a.txt", env / "Dockerfile"]
39
+ diffs = _collect_source_diffs(env, lock)
40
+ assert "Dockerfile" in diffs["added"]
41
+ assert "b.txt" in diffs["removed"]
42
+ assert "a.txt" in diffs["modified"] or "a.txt" in diffs["added"]
43
+
44
+
45
+ def test_find_environment_dir_prefers_lock(tmp_path: Path):
46
+ # Create env as a sibling to tasks, so it will be in the candidates list
47
+ parent = tmp_path / "parent"
48
+ parent.mkdir()
49
+ tasks = parent / "tasks.json"
50
+ tasks.write_text("[]")
51
+ env = tmp_path / "env"
52
+ env.mkdir()
53
+ (env / "hud.lock.yaml").write_text("version: 1.0")
54
+ # Set cwd to env so it's in the candidate list
55
+ with patch("pathlib.Path.cwd", return_value=env):
56
+ found = find_environment_dir(tasks)
57
+ # Should find env because cwd returns env and it has hud.lock.yaml
58
+ assert found == env
59
+
60
+
61
+ def test_ensure_built_no_lock_noninteractive(tmp_path: Path):
62
+ env = tmp_path / "e"
63
+ env.mkdir()
64
+ # Non-interactive: returns empty dict and does not raise
65
+ result = ensure_built(env, interactive=False)
66
+ assert result == {}
67
+
68
+
69
+ def test_ensure_built_interactive_build(tmp_path: Path):
70
+ env = tmp_path / "e"
71
+ env.mkdir()
72
+ # Simulate interactive=False path avoids prompts
73
+ result = ensure_built(env, interactive=False)
74
+ assert result == {}
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from hud.cli.utils.environment import get_image_name, image_exists, is_environment_directory
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ def test_get_image_name_override():
13
+ name, source = get_image_name(".", image_override="custom:dev")
14
+ assert name == "custom:dev" and source == "override"
15
+
16
+
17
+ def test_get_image_name_auto(tmp_path: Path):
18
+ env = tmp_path / "my_env"
19
+ env.mkdir()
20
+ # Provide Dockerfile and pyproject to pass directory check later if used
21
+ (env / "Dockerfile").write_text("FROM python:3.11")
22
+ (env / "pyproject.toml").write_text("[tool.hud]\nimage='x'")
23
+ name, source = get_image_name(env)
24
+ # Because pyproject exists with image key, source should be cache
25
+ assert source == "cache"
26
+ assert name == "x"
27
+
28
+
29
+ def test_is_environment_directory(tmp_path: Path):
30
+ d = tmp_path / "env"
31
+ d.mkdir()
32
+ assert is_environment_directory(d) is False
33
+ (d / "Dockerfile").write_text("FROM python:3.11")
34
+ assert is_environment_directory(d) is False
35
+ (d / "pyproject.toml").write_text("[tool.hud]")
36
+ assert is_environment_directory(d) is True
37
+
38
+
39
+ @patch("subprocess.run")
40
+ def test_image_exists_true(mock_run):
41
+ mock_run.return_value = MagicMock(returncode=0)
42
+ assert image_exists("img") is True
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.interactive import InteractiveMCPTester
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ @patch("hud.cli.utils.interactive.MCPClient")
13
+ async def test_connect_and_disconnect(MockClient):
14
+ client = AsyncMock()
15
+ client.initialize.return_value = None
16
+ client.list_tools.return_value = []
17
+ client.shutdown.return_value = None
18
+ MockClient.return_value = client
19
+
20
+ tester = InteractiveMCPTester("http://localhost:8765/mcp", verbose=False)
21
+ ok = await tester.connect()
22
+ assert ok is True
23
+ assert tester.tools == []
24
+ await tester.disconnect()
25
+
26
+
27
+ def test_display_tools_handles_empty(capfd):
28
+ tester = InteractiveMCPTester("http://x")
29
+ tester.tools = []
30
+ tester.display_tools() # prints warning
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ @patch("hud.cli.utils.interactive.questionary")
35
+ async def test_select_tool_quit(mock_questionary):
36
+ tester = InteractiveMCPTester("http://x")
37
+ tester.tools = [SimpleNamespace(name="a", description="")]
38
+ # Simulate ESC/quit
39
+ mock_questionary.select.return_value.unsafe_ask_async.return_value = "❌ Quit"
40
+ sel = await tester.select_tool()
41
+ assert sel is None
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ @patch("hud.cli.utils.interactive.console")
46
+ async def test_get_tool_arguments_no_schema(mock_console):
47
+ tester = InteractiveMCPTester("http://x")
48
+ args = await tester.get_tool_arguments(SimpleNamespace(name="t", inputSchema=None))
49
+ assert args == {}
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ @patch("hud.cli.utils.interactive.console")
54
+ async def test_call_tool_success(mock_console):
55
+ tester = InteractiveMCPTester("http://x")
56
+ fake_result = SimpleNamespace(isError=False, content=[SimpleNamespace(text="ok")])
57
+ tester.client = AsyncMock()
58
+ tester.client.call_tool.return_value = fake_result
59
+ await tester.call_tool(SimpleNamespace(name="t"), {"a": 1})
60
+ assert tester.client.call_tool.awaited
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from unittest import mock
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.local_runner import run_local_server, run_with_reload
9
+
10
+ pytestmark = pytest.mark.skipif(
11
+ sys.platform == "win32", reason="Prefers Linux (signal/stdio nuances)"
12
+ )
13
+
14
+
15
+ @mock.patch("subprocess.run")
16
+ def test_run_local_server_no_reload_http(mock_run, monkeypatch):
17
+ mock_run.return_value = mock.Mock(returncode=0)
18
+ # Ensure sys.exit is raised with code 0
19
+ with pytest.raises(SystemExit) as exc:
20
+ run_local_server("server:app", transport="http", port=8765, verbose=True, reload=False)
21
+ assert exc.value.code == 0
22
+ # Verify the command contained port and no-banner
23
+ args = mock_run.call_args[0][0]
24
+ assert "--port" in args and "--no-banner" in args
25
+
26
+
27
+ @mock.patch("hud.cli.utils.local_runner.run_with_reload")
28
+ def test_run_local_server_reload_calls_reload(mock_reload):
29
+ run_local_server("server:app", transport="stdio", port=None, verbose=False, reload=True)
30
+ mock_reload.assert_called_once()
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_run_with_reload_import_error(monkeypatch):
35
+ # Force ImportError for watchfiles
36
+ import builtins as _builtins
37
+
38
+ real_import = _builtins.__import__
39
+
40
+ def _imp(name, *args, **kwargs):
41
+ if name == "watchfiles":
42
+ raise ImportError("nope")
43
+ return real_import(name, *args, **kwargs)
44
+
45
+ monkeypatch.setattr(_builtins, "__import__", _imp)
46
+
47
+ with pytest.raises(SystemExit) as exc:
48
+ # run_with_reload is async in this module; await it
49
+ await run_with_reload("server:app", transport="stdio", verbose=False)
50
+ assert exc.value.code == 1
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from hud.cli.utils.logging import CaptureLogger, analyze_error_for_hints, is_port_free
4
+
5
+
6
+ def test_capture_logger_basic(capfd):
7
+ logger = CaptureLogger(print_output=True)
8
+ logger.success("done")
9
+ logger.error("oops")
10
+ logger.info("info")
11
+ out = logger.get_output()
12
+ assert "done" in out and "oops" in out and "info" in out
13
+
14
+
15
+ def test_analyze_error_for_hints_matches():
16
+ hint = analyze_error_for_hints("ModuleNotFoundError: x")
17
+ assert hint and "dependencies" in hint
18
+
19
+
20
+ def test_is_port_free_returns_bool():
21
+ # Probe a high port; we only assert the function returns a boolean
22
+ free = is_port_free(65500)
23
+ assert isinstance(free, bool)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.metadata import (
9
+ analyze_from_metadata,
10
+ check_local_cache,
11
+ fetch_lock_from_registry,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+
18
+ @patch("hud.cli.utils.metadata.settings")
19
+ @patch("requests.get")
20
+ def test_fetch_lock_from_registry_success(mock_get, mock_settings):
21
+ mock_settings.hud_telemetry_url = "https://api.example.com"
22
+ mock_settings.api_key = None
23
+ resp = MagicMock(status_code=200)
24
+ resp.json.return_value = {"lock": "image: img\n"}
25
+ mock_get.return_value = resp
26
+ lock = fetch_lock_from_registry("org/name:tag")
27
+ assert lock is not None and lock["image"] == "img"
28
+
29
+
30
+ def test_check_local_cache_not_found(tmp_path: Path, monkeypatch):
31
+ # Point registry to empty dir
32
+ from hud.cli.utils import registry as reg
33
+
34
+ monkeypatch.setattr(reg, "get_registry_dir", lambda: tmp_path)
35
+ assert check_local_cache("org/name:tag") is None
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ @patch("hud.cli.utils.metadata.console")
40
+ @patch("hud.cli.utils.metadata.list_registry_entries")
41
+ @patch("hud.cli.utils.metadata.load_from_registry")
42
+ @patch("hud.cli.utils.metadata.extract_digest_from_image")
43
+ async def test_analyze_from_metadata_local(mock_extract, mock_load, mock_list, mock_console):
44
+ mock_extract.return_value = "abcd"
45
+ mock_load.return_value = {"image": "img", "environment": {"toolCount": 0}}
46
+ mock_list.return_value = []
47
+ await analyze_from_metadata("img@sha256:abcd", "json", verbose=False)
48
+ # Should print JSON
49
+ assert mock_console.print_json.called