hud-python 0.4.51__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.
- hud/__init__.py +13 -1
- hud/agents/base.py +14 -3
- hud/agents/lite_llm.py +1 -1
- hud/agents/openai_chat_generic.py +15 -3
- hud/agents/tests/test_base.py +9 -2
- hud/agents/tests/test_base_runtime.py +164 -0
- hud/cli/__init__.py +18 -25
- hud/cli/build.py +35 -27
- hud/cli/dev.py +11 -29
- hud/cli/eval.py +114 -145
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +26 -3
- 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 +4 -0
- 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/tasks.py +4 -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 +257 -0
- hud/clients/base.py +1 -1
- hud/clients/mcp_use.py +3 -1
- hud/datasets/parallel.py +2 -2
- hud/datasets/runner.py +85 -24
- 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/config.py +8 -6
- hud/otel/context.py +4 -4
- hud/otel/exporters.py +231 -57
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_instrumentation.py +207 -0
- hud/rl/learner.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/shared/exceptions.py +35 -9
- hud/shared/hints.py +25 -0
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +39 -30
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +30 -6
- hud/telemetry/async_context.py +331 -0
- hud/telemetry/job.py +51 -12
- 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 +184 -6
- hud/telemetry/trace.py +16 -17
- hud/tools/computer/qwen.py +4 -1
- hud/tools/computer/settings.py +2 -2
- hud/tools/executors/base.py +4 -2
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/types.py +7 -1
- hud/utils/agent_factories.py +1 -3
- hud/utils/mcp.py +1 -1
- hud/utils/task_tracking.py +223 -0
- 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.51.dist-info → hud_python-0.4.53.dist-info}/METADATA +48 -48
- {hud_python-0.4.51.dist-info → hud_python-0.4.53.dist-info}/RECORD +88 -47
- {hud_python-0.4.51.dist-info → hud_python-0.4.53.dist-info}/WHEEL +0 -0
- {hud_python-0.4.51.dist-info → hud_python-0.4.53.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.51.dist-info → hud_python-0.4.53.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
@@ -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
|
hud/cli/tests/test_eval.py
CHANGED
|
@@ -332,6 +332,7 @@ class TestRunDatasetToolFiltering:
|
|
|
332
332
|
patch.object(ClaudeAgent, "_run_context", mock_run_context),
|
|
333
333
|
patch.object(ClaudeAgent, "call_tools", mock_call_tools),
|
|
334
334
|
patch("hud.clients.MCPClient", return_value=mock_client_instance),
|
|
335
|
+
patch("hud.settings.settings.anthropic_api_key", "sk-test-key"),
|
|
335
336
|
):
|
|
336
337
|
# Run the dataset
|
|
337
338
|
await run_dataset(
|
|
@@ -400,6 +401,7 @@ class TestRunDatasetToolFiltering:
|
|
|
400
401
|
patch.object(ClaudeAgent, "_run_context", mock_run_context),
|
|
401
402
|
patch.object(ClaudeAgent, "call_tools", mock_call_tools),
|
|
402
403
|
patch("hud.clients.MCPClient", return_value=mock_client_instance),
|
|
404
|
+
patch("hud.settings.settings.anthropic_api_key", "sk-test-key"),
|
|
403
405
|
):
|
|
404
406
|
# Run the dataset
|
|
405
407
|
await run_dataset(
|
|
@@ -500,6 +502,7 @@ class TestSystemPromptHandling:
|
|
|
500
502
|
patch.object(ClaudeAgent, "_run_context", mock_run_context),
|
|
501
503
|
patch.object(ClaudeAgent, "call_tools", mock_call_tools),
|
|
502
504
|
patch("hud.clients.MCPClient", return_value=mock_mcp_client),
|
|
505
|
+
patch("hud.settings.settings.anthropic_api_key", "sk-test-key"),
|
|
503
506
|
):
|
|
504
507
|
# Run the dataset
|
|
505
508
|
await run_dataset(
|
|
@@ -551,6 +554,7 @@ class TestSystemPromptHandling:
|
|
|
551
554
|
patch.object(ClaudeAgent, "_run_context", mock_run_context),
|
|
552
555
|
patch.object(ClaudeAgent, "call_tools", mock_call_tools),
|
|
553
556
|
patch("hud.clients.MCPClient", return_value=mock_mcp_client),
|
|
557
|
+
patch("hud.settings.settings.anthropic_api_key", "sk-test-key"),
|
|
554
558
|
):
|
|
555
559
|
# Run the dataset
|
|
556
560
|
await run_dataset(
|
hud/cli/tests/test_mcp_server.py
CHANGED
|
@@ -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
|
-
"""
|
|
19
|
-
import
|
|
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.dev.
|
|
23
|
-
patch("
|
|
24
|
-
|
|
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=
|
|
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:
|
hud/cli/utils/tasks.py
CHANGED
|
@@ -18,9 +18,12 @@ def find_tasks_file(tasks_file: str | None, msg: str = "Select a tasks file") ->
|
|
|
18
18
|
]
|
|
19
19
|
all_files = [file for file in all_files if file[0] != "."] # Remove all config files
|
|
20
20
|
|
|
21
|
+
if not all_files:
|
|
22
|
+
# No task files found - raise a clear exception
|
|
23
|
+
raise FileNotFoundError("No task JSON or JSONL files found in current directory")
|
|
24
|
+
|
|
21
25
|
if len(all_files) == 1:
|
|
22
26
|
return str(all_files[0])
|
|
23
|
-
|
|
24
27
|
else:
|
|
25
28
|
# Prompt user to select a file
|
|
26
29
|
return hud_console.select(msg, choices=all_files)
|
|
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()
|