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
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ import hud.cli as cli
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+
14
+ @patch("hud.cli.utils.metadata.analyze_from_metadata", new_callable=AsyncMock)
15
+ @patch("asyncio.run")
16
+ def test_analyze_params_metadata(mock_run, mock_analyze):
17
+ # image only -> metadata path
18
+ cli.analyze(params=["img:latest"], output_format="json", verbose=False)
19
+ assert mock_run.called
20
+
21
+
22
+ @patch("hud.cli.analyze.analyze_environment", new_callable=AsyncMock)
23
+ @patch("hud.cli.utils.docker.build_run_command")
24
+ @patch("asyncio.run")
25
+ def test_analyze_params_live(mock_run, mock_build_cmd, mock_analyze_env):
26
+ mock_build_cmd.return_value = ["docker", "run", "img", "-e", "K=V"]
27
+ # docker args trigger live path
28
+ cli.analyze(params=["img:latest", "-e", "K=V"], output_format="json", verbose=True)
29
+ assert mock_run.called
30
+
31
+
32
+ def test_analyze_no_params_errors():
33
+ import typer
34
+
35
+ # When no params provided, analyze prints help and exits(1)
36
+ with pytest.raises(typer.Exit):
37
+ cli.analyze(params=None, config=None, cursor=None, output_format="json", verbose=False) # type: ignore
38
+
39
+
40
+ @patch("hud.cli.analyze.analyze_environment_from_config", new_callable=AsyncMock)
41
+ @patch("asyncio.run")
42
+ def test_analyze_from_config(mock_run, mock_func, tmp_path: Path):
43
+ cfg = tmp_path / "cfg.json"
44
+ cfg.write_text("{}")
45
+ cli.analyze(params=None, config=cfg, cursor=None, output_format="json", verbose=False) # type: ignore
46
+ assert mock_run.called
47
+
48
+
49
+ @patch("hud.cli.parse_cursor_config")
50
+ @patch("hud.cli.analyze.analyze_environment_from_mcp_config", new_callable=AsyncMock)
51
+ @patch("asyncio.run")
52
+ def test_analyze_from_cursor(mock_run, mock_analyze, mock_parse):
53
+ mock_parse.return_value = (["cmd", "arg"], None)
54
+ cli.analyze(params=None, config=None, cursor="server", output_format="json", verbose=False) # type: ignore
55
+ assert mock_run.called
56
+
57
+
58
+ @patch("hud.cli.build_command")
59
+ def test_build_env_var_parsing(mock_build):
60
+ cli.build(
61
+ params=[".", "-e", "A=B", "--env=C=D", "--env", "E=F"],
62
+ tag=None,
63
+ no_cache=False,
64
+ verbose=False,
65
+ platform=None,
66
+ )
67
+ assert mock_build.called
68
+ # args: directory, tag, no_cache, verbose, env_vars, platform
69
+ env_vars = mock_build.call_args[0][4]
70
+ assert env_vars == {"A": "B", "C": "D", "E": "F"}
71
+
72
+
73
+ @patch("hud.cli.utils.runner.run_mcp_server")
74
+ def test_run_local_calls_runner(mock_runner):
75
+ cli.run(
76
+ params=["img:latest"],
77
+ local=True,
78
+ transport="stdio",
79
+ port=1234,
80
+ url=None, # type: ignore
81
+ api_key=None,
82
+ run_id=None,
83
+ verbose=False,
84
+ )
85
+ assert mock_runner.called
86
+
87
+
88
+ @patch("hud.cli.utils.remote_runner.run_remote_server")
89
+ def test_run_remote_calls_remote(mock_remote):
90
+ cli.run(
91
+ params=["img:latest"],
92
+ local=False,
93
+ transport="http",
94
+ port=8765,
95
+ url="https://x",
96
+ api_key=None,
97
+ run_id=None,
98
+ verbose=True,
99
+ )
100
+ assert mock_remote.called
101
+
102
+
103
+ def test_run_no_params_errors():
104
+ import typer
105
+
106
+ with pytest.raises(typer.Exit):
107
+ cli.run(params=None) # type: ignore
108
+
109
+
110
+ @patch("hud.cli.run_mcp_dev_server")
111
+ def test_dev_calls_runner(mock_dev):
112
+ cli.dev(
113
+ params=["server.main"],
114
+ docker=False,
115
+ stdio=False,
116
+ port=9000,
117
+ verbose=False,
118
+ inspector=False,
119
+ interactive=False,
120
+ watch=None, # type: ignore
121
+ )
122
+ assert mock_dev.called
123
+
124
+
125
+ @patch("hud.cli.pull_command")
126
+ def test_pull_command_wrapper(mock_pull):
127
+ cli.pull(target="org/name:tag", lock_file=None, yes=True, verify_only=True, verbose=False)
128
+ assert mock_pull.called
129
+
130
+
131
+ @patch("hud.cli.push_command")
132
+ def test_push_command_wrapper(mock_push, tmp_path: Path):
133
+ cli.push(directory=str(tmp_path), image=None, tag=None, sign=False, yes=True, verbose=True)
134
+ assert mock_push.called
@@ -11,7 +11,7 @@ from hud.cli.eval import (
11
11
  build_agent,
12
12
  run_single_task,
13
13
  )
14
- from hud.types import Task, Trace
14
+ from hud.types import AgentType, Task, Trace
15
15
 
16
16
 
17
17
  class TestBuildAgent:
@@ -26,7 +26,7 @@ class TestBuildAgent:
26
26
  mock_runner.return_value = mock_instance
27
27
 
28
28
  # Test with verbose=False
29
- result = build_agent("integration_test", verbose=False)
29
+ result = build_agent(AgentType.INTEGRATION_TEST, verbose=False)
30
30
 
31
31
  mock_runner.assert_called_once_with(verbose=False)
32
32
  assert result == mock_instance
@@ -40,7 +40,7 @@ class TestBuildAgent:
40
40
  mock_runner.return_value = mock_instance
41
41
 
42
42
  # Test with verbose=False
43
- result = build_agent("claude", verbose=False)
43
+ result = build_agent(AgentType.CLAUDE, verbose=False)
44
44
 
45
45
  mock_runner.assert_called_once_with(model="claude-sonnet-4-20250514", verbose=False)
46
46
  assert result == mock_instance
@@ -55,7 +55,7 @@ class TestBuildAgent:
55
55
 
56
56
  # Test with verbose=False
57
57
  result = build_agent(
58
- "claude",
58
+ AgentType.CLAUDE,
59
59
  model="claude-sonnet-4-20250514",
60
60
  allowed_tools=["act"],
61
61
  verbose=True,
@@ -97,7 +97,7 @@ class TestRunSingleTask:
97
97
  patch("hud.cli.eval.find_environment_dir", return_value=None),
98
98
  patch("hud.cli.eval.hud.trace"),
99
99
  ):
100
- await run_single_task("test.json", agent_type="integration_test", max_steps=10)
100
+ await run_single_task("test.json", agent_type=AgentType.INTEGRATION_TEST, max_steps=10)
101
101
 
102
102
  # Verify agent.run was called with the task containing agent_config
103
103
  mock_agent.run.assert_called_once()
@@ -119,7 +119,7 @@ class TestRunSingleTask:
119
119
  mock_grouped.return_value = [{"task": mock_task, "rewards": [1.0, 0.5]}]
120
120
 
121
121
  await run_single_task(
122
- "test.json", agent_type="integration_test", group_size=3, max_steps=10
122
+ "test.json", agent_type=AgentType.INTEGRATION_TEST, group_size=3, max_steps=10
123
123
  )
124
124
 
125
125
  # Verify run_tasks_grouped was called with correct group_size
@@ -15,22 +15,23 @@ class TestRunMCPDevServer:
15
15
  """Test the main server runner."""
16
16
 
17
17
  def test_run_dev_server_image_not_found(self) -> None:
18
- """Test handling when Docker image doesn't exist."""
19
- import click
18
+ """When using Docker mode without a lock file, exits with typer.Exit(1)."""
19
+ import typer
20
20
 
21
21
  with (
22
- patch("hud.cli.utils.environment.image_exists", return_value=False),
23
- patch("click.confirm", return_value=False),
24
- pytest.raises(click.Abort),
22
+ patch("hud.cli.dev.should_use_docker_mode", return_value=True),
23
+ patch("hud.cli.dev.Path.cwd"),
24
+ patch("hud.cli.dev.hud_console"),
25
+ pytest.raises(typer.Exit),
25
26
  ):
26
27
  run_mcp_dev_server(
27
- module=".",
28
+ module=None,
28
29
  stdio=False,
29
30
  port=8765,
30
31
  verbose=False,
31
32
  inspector=False,
32
33
  interactive=False,
33
34
  watch=[],
34
- docker=False,
35
+ docker=True,
35
36
  docker_args=[],
36
37
  )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import TYPE_CHECKING
5
+ from unittest.mock import patch
6
+
7
+ from hud.cli.push import push_environment
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ @patch("hud.cli.push.get_docker_username", return_value="tester")
14
+ @patch(
15
+ "hud.cli.push.get_docker_image_labels",
16
+ return_value={"org.hud.manifest.head": "abc", "org.hud.version": "0.1.0"},
17
+ )
18
+ @patch("hud.cli.push.requests.post")
19
+ @patch("hud.cli.push.subprocess.Popen")
20
+ @patch("hud.cli.push.subprocess.run")
21
+ def test_push_happy_path(
22
+ mock_run, mock_popen, mock_post, _labels, _user, tmp_path: Path, monkeypatch
23
+ ):
24
+ # Prepare minimal environment with lock file
25
+ env_dir = tmp_path
26
+ (env_dir / "hud.lock.yaml").write_text(
27
+ "images:\n local: org/env:latest\nbuild:\n version: 0.1.0\n"
28
+ )
29
+
30
+ # Provide API key via main settings module
31
+ monkeypatch.setattr("hud.settings.settings.api_key", "sk-test", raising=False)
32
+
33
+ # ensure_built noop - patch from the right module
34
+ monkeypatch.setattr("hud.cli.utils.env_check.ensure_built", lambda *_a, **_k: {})
35
+
36
+ # Mock subprocess.run behavior depending on command
37
+ def run_side_effect(args, *a, **k):
38
+ cmd = list(args)
39
+ # docker inspect checks
40
+ if cmd[:2] == ["docker", "inspect"]:
41
+ # For label digest query at end
42
+ if "--format" in cmd and "{{index .RepoDigests 0}}" in cmd:
43
+ return SimpleNamespace(returncode=0, stdout="org/env@sha256:deadbeef")
44
+ # Existence checks succeed
45
+ return SimpleNamespace(returncode=0, stdout="")
46
+ # docker tag success
47
+ if cmd[:2] == ["docker", "tag"]:
48
+ return SimpleNamespace(returncode=0, stdout="")
49
+ return SimpleNamespace(returncode=0, stdout="")
50
+
51
+ mock_run.side_effect = run_side_effect
52
+
53
+ # Mock Popen push pipeline
54
+ class _Proc:
55
+ def __init__(self):
56
+ self.stdout = ["digest: sha256:deadbeef\n", "pushed\n"]
57
+ self.returncode = 0
58
+
59
+ def wait(self):
60
+ return 0
61
+
62
+ mock_popen.return_value = _Proc()
63
+
64
+ # Mock registry POST success
65
+ mock_post.return_value = SimpleNamespace(status_code=201, json=lambda: {"ok": True}, text="")
66
+
67
+ # Execute
68
+ push_environment(
69
+ directory=str(env_dir), image=None, tag=None, sign=False, yes=True, verbose=False
70
+ )
71
+
72
+ # Lock file updated with pushed entry
73
+ data = (env_dir / "hud.lock.yaml").read_text()
74
+ assert "pushed:" in data
@@ -0,0 +1,23 @@
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.push import push_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.push.ensure_built")
16
+ @patch("hud.cli.push.HUDConsole")
17
+ @patch("hud.cli.push.subprocess.run")
18
+ def test_push_environment_missing_lock_raises(mock_run, mock_console, _ensure, tmp_path: Path):
19
+ # No hud.lock.yaml → Exit(1)
20
+ with pytest.raises(typer.Exit):
21
+ push_environment(
22
+ directory=str(tmp_path), image=None, tag=None, sign=False, yes=True, verbose=False
23
+ )
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")