hud-python 0.4.45__py3-none-any.whl → 0.5.1__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.
- hud/__init__.py +27 -7
- hud/agents/__init__.py +11 -5
- hud/agents/base.py +220 -500
- hud/agents/claude.py +200 -240
- hud/agents/gemini.py +275 -0
- hud/agents/gemini_cua.py +335 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +41 -36
- hud/agents/openai.py +291 -292
- hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
- hud/agents/operator.py +211 -0
- hud/agents/tests/conftest.py +133 -0
- hud/agents/tests/test_base.py +300 -622
- hud/agents/tests/test_base_runtime.py +233 -0
- hud/agents/tests/test_claude.py +379 -210
- hud/agents/tests/test_client.py +9 -10
- hud/agents/tests/test_gemini.py +369 -0
- hud/agents/tests/test_grounded_openai_agent.py +65 -50
- hud/agents/tests/test_openai.py +376 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/cli/__init__.py +461 -545
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +664 -110
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +882 -734
- hud/cli/eval.py +782 -668
- hud/cli/flows/dev.py +167 -0
- hud/cli/flows/init.py +191 -0
- hud/cli/flows/tasks.py +153 -56
- hud/cli/flows/templates.py +151 -0
- hud/cli/flows/tests/__init__.py +1 -0
- hud/cli/flows/tests/test_dev.py +126 -0
- hud/cli/init.py +60 -58
- hud/cli/push.py +29 -11
- hud/cli/rft.py +311 -0
- hud/cli/rft_status.py +145 -0
- hud/cli/tests/test_analyze.py +5 -5
- hud/cli/tests/test_analyze_metadata.py +3 -2
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +108 -6
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_init.py +6 -1
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +140 -0
- hud/cli/tests/test_convert.py +361 -0
- hud/cli/tests/test_debug.py +12 -10
- hud/cli/tests/test_dev.py +197 -0
- hud/cli/tests/test_eval.py +251 -0
- hud/cli/tests/test_eval_bedrock.py +51 -0
- hud/cli/tests/test_init.py +124 -0
- hud/cli/tests/test_main_module.py +11 -5
- hud/cli/tests/test_mcp_server.py +12 -100
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/tests/test_registry.py +1 -1
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/{rl → utils}/celebrate.py +14 -12
- hud/cli/utils/config.py +18 -1
- hud/cli/utils/docker.py +130 -4
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/git.py +136 -0
- hud/cli/utils/interactive.py +39 -5
- hud/cli/utils/metadata.py +69 -0
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/server.py +2 -2
- hud/cli/utils/source_hash.py +3 -3
- 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_git.py +142 -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 +258 -0
- hud/cli/{rl → utils}/viewer.py +2 -2
- hud/clients/README.md +12 -11
- hud/clients/__init__.py +4 -3
- hud/clients/base.py +166 -26
- hud/clients/environment.py +51 -0
- hud/clients/fastmcp.py +13 -6
- hud/clients/mcp_use.py +40 -15
- hud/clients/tests/test_analyze_scenarios.py +206 -0
- hud/clients/tests/test_protocol.py +9 -3
- hud/datasets/__init__.py +23 -20
- hud/datasets/loader.py +327 -0
- hud/datasets/runner.py +192 -105
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_loader.py +221 -0
- hud/datasets/tests/test_utils.py +315 -0
- hud/datasets/utils.py +270 -90
- hud/environment/__init__.py +50 -0
- hud/environment/connection.py +206 -0
- hud/environment/connectors/__init__.py +33 -0
- hud/environment/connectors/base.py +68 -0
- hud/environment/connectors/local.py +177 -0
- hud/environment/connectors/mcp_config.py +109 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +694 -0
- hud/environment/integrations/__init__.py +45 -0
- hud/environment/integrations/adk.py +67 -0
- hud/environment/integrations/anthropic.py +196 -0
- hud/environment/integrations/gemini.py +92 -0
- hud/environment/integrations/langchain.py +82 -0
- hud/environment/integrations/llamaindex.py +68 -0
- hud/environment/integrations/openai.py +238 -0
- hud/environment/mock.py +306 -0
- hud/environment/router.py +112 -0
- hud/environment/scenarios.py +493 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +218 -0
- hud/environment/tests/test_environment.py +161 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +201 -0
- hud/environment/tests/test_scenarios.py +280 -0
- hud/environment/tests/test_tools.py +208 -0
- hud/environment/types.py +23 -0
- hud/environment/utils/__init__.py +35 -0
- hud/environment/utils/formats.py +215 -0
- hud/environment/utils/schema.py +171 -0
- hud/environment/utils/tool_wrappers.py +113 -0
- hud/eval/__init__.py +67 -0
- hud/eval/context.py +674 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +185 -0
- hud/eval/manager.py +466 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +340 -0
- hud/eval/tests/__init__.py +1 -0
- hud/eval/tests/test_context.py +178 -0
- hud/eval/tests/test_eval.py +210 -0
- hud/eval/tests/test_manager.py +152 -0
- hud/eval/tests/test_parallel.py +168 -0
- hud/eval/tests/test_task.py +145 -0
- hud/eval/types.py +63 -0
- hud/eval/utils.py +183 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +151 -0
- hud/patches/warnings.py +54 -0
- hud/samples/browser.py +4 -4
- hud/server/__init__.py +2 -1
- hud/server/low_level.py +2 -1
- hud/server/router.py +164 -0
- hud/server/server.py +567 -80
- hud/server/tests/test_mcp_server_integration.py +11 -11
- hud/server/tests/test_mcp_server_more.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/settings.py +45 -3
- hud/shared/exceptions.py +36 -10
- hud/shared/hints.py +26 -1
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +40 -31
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +20 -19
- hud/telemetry/exporter.py +201 -0
- hud/telemetry/instrument.py +158 -253
- hud/telemetry/tests/test_eval_telemetry.py +356 -0
- hud/telemetry/tests/test_exporter.py +258 -0
- hud/telemetry/tests/test_instrument.py +401 -0
- hud/tools/__init__.py +16 -2
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +4 -0
- hud/tools/computer/anthropic.py +2 -2
- hud/tools/computer/gemini.py +385 -0
- hud/tools/computer/hud.py +23 -6
- hud/tools/computer/openai.py +20 -21
- hud/tools/computer/qwen.py +434 -0
- hud/tools/computer/settings.py +37 -0
- hud/tools/edit.py +3 -7
- hud/tools/executors/base.py +4 -2
- hud/tools/executors/pyautogui.py +1 -1
- hud/tools/grounding/grounded_tool.py +13 -18
- hud/tools/grounding/grounder.py +10 -31
- hud/tools/grounding/tests/test_grounded_tool.py +26 -44
- hud/tools/jupyter.py +330 -0
- hud/tools/playwright.py +18 -3
- hud/tools/shell.py +308 -0
- hud/tools/tests/test_apply_patch.py +718 -0
- hud/tools/tests/test_computer.py +4 -9
- hud/tools/tests/test_computer_actions.py +24 -2
- hud/tools/tests/test_jupyter_tool.py +181 -0
- hud/tools/tests/test_shell.py +596 -0
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/tools/types.py +21 -1
- hud/types.py +167 -57
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +61 -3
- hud/utils/mcp.py +15 -58
- hud/utils/strict_schema.py +162 -0
- hud/utils/tests/test_init.py +1 -2
- hud/utils/tests/test_mcp.py +1 -28
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/utils/types.py +20 -0
- hud/version.py +1 -1
- hud_python-0.5.1.dist-info/METADATA +264 -0
- hud_python-0.5.1.dist-info/RECORD +299 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
- hud/agents/langchain.py +0 -261
- hud/agents/lite_llm.py +0 -72
- hud/cli/rl/__init__.py +0 -180
- hud/cli/rl/config.py +0 -101
- hud/cli/rl/display.py +0 -133
- hud/cli/rl/gpu.py +0 -63
- hud/cli/rl/gpu_utils.py +0 -321
- hud/cli/rl/local_runner.py +0 -595
- hud/cli/rl/presets.py +0 -96
- hud/cli/rl/remote_runner.py +0 -463
- hud/cli/rl/rl_api.py +0 -150
- hud/cli/rl/vllm.py +0 -177
- hud/cli/rl/wait_utils.py +0 -89
- hud/datasets/parallel.py +0 -687
- hud/misc/__init__.py +0 -1
- hud/misc/claude_plays_pokemon.py +0 -292
- hud/otel/__init__.py +0 -35
- hud/otel/collector.py +0 -142
- hud/otel/config.py +0 -181
- hud/otel/context.py +0 -570
- hud/otel/exporters.py +0 -369
- hud/otel/instrumentation.py +0 -135
- hud/otel/processors.py +0 -121
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_processors.py +0 -197
- hud/rl/README.md +0 -30
- hud/rl/__init__.py +0 -1
- hud/rl/actor.py +0 -176
- hud/rl/buffer.py +0 -405
- hud/rl/chat_template.jinja +0 -101
- hud/rl/config.py +0 -192
- hud/rl/distributed.py +0 -132
- hud/rl/learner.py +0 -637
- hud/rl/tests/__init__.py +0 -1
- hud/rl/tests/test_learner.py +0 -186
- hud/rl/train.py +0 -382
- hud/rl/types.py +0 -101
- hud/rl/utils/start_vllm_server.sh +0 -30
- hud/rl/utils.py +0 -524
- hud/rl/vllm_adapter.py +0 -143
- hud/telemetry/job.py +0 -352
- hud/telemetry/replay.py +0 -74
- hud/telemetry/tests/test_replay.py +0 -40
- hud/telemetry/tests/test_trace.py +0 -63
- hud/telemetry/trace.py +0 -158
- hud/utils/agent_factories.py +0 -86
- hud/utils/async_utils.py +0 -65
- hud/utils/group_eval.py +0 -223
- hud/utils/progress.py +0 -149
- hud/utils/tasks.py +0 -127
- hud/utils/tests/test_async_utils.py +0 -173
- hud/utils/tests/test_progress.py +0 -261
- hud_python-0.4.45.dist-info/METADATA +0 -552
- hud_python-0.4.45.dist-info/RECORD +0 -228
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Tests for git utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from hud.cli.utils.git import get_git_info, get_git_remote_url, normalize_github_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestNormalizeGithubUrl:
|
|
11
|
+
"""Test GitHub URL normalization."""
|
|
12
|
+
|
|
13
|
+
def test_normalize_ssh_url(self):
|
|
14
|
+
"""Test normalizing SSH format URL."""
|
|
15
|
+
url = "git@github.com:user/repo.git"
|
|
16
|
+
result = normalize_github_url(url)
|
|
17
|
+
assert result == "https://github.com/user/repo"
|
|
18
|
+
|
|
19
|
+
def test_normalize_https_with_git_suffix(self):
|
|
20
|
+
"""Test normalizing HTTPS URL with .git suffix."""
|
|
21
|
+
url = "https://github.com/user/repo.git"
|
|
22
|
+
result = normalize_github_url(url)
|
|
23
|
+
assert result == "https://github.com/user/repo"
|
|
24
|
+
|
|
25
|
+
def test_normalize_git_protocol(self):
|
|
26
|
+
"""Test normalizing git:// protocol URL."""
|
|
27
|
+
url = "git://github.com/user/repo.git"
|
|
28
|
+
result = normalize_github_url(url)
|
|
29
|
+
assert result == "https://github.com/user/repo"
|
|
30
|
+
|
|
31
|
+
def test_normalize_already_clean(self):
|
|
32
|
+
"""Test URL that's already normalized."""
|
|
33
|
+
url = "https://github.com/user/repo"
|
|
34
|
+
result = normalize_github_url(url)
|
|
35
|
+
assert result == "https://github.com/user/repo"
|
|
36
|
+
|
|
37
|
+
def test_normalize_with_github_com_colon(self):
|
|
38
|
+
"""Test URL with github.com: format."""
|
|
39
|
+
url = "ssh://github.com:user/repo.git"
|
|
40
|
+
result = normalize_github_url(url)
|
|
41
|
+
assert result == "https://github.com/user/repo"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestGetGitRemoteUrl:
|
|
45
|
+
"""Test getting git remote URL."""
|
|
46
|
+
|
|
47
|
+
@mock.patch("subprocess.run")
|
|
48
|
+
def test_get_remote_url_success(self, mock_run):
|
|
49
|
+
"""Test successfully getting remote URL."""
|
|
50
|
+
# First call checks if we're in a git repo
|
|
51
|
+
mock_run.side_effect = [
|
|
52
|
+
mock.Mock(returncode=0), # git rev-parse --git-dir
|
|
53
|
+
mock.Mock(returncode=0, stdout="git@github.com:user/repo.git\n"), # git config
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
result = get_git_remote_url()
|
|
57
|
+
assert result == "https://github.com/user/repo"
|
|
58
|
+
|
|
59
|
+
@mock.patch("subprocess.run")
|
|
60
|
+
def test_get_remote_url_not_git_repo(self, mock_run):
|
|
61
|
+
"""Test when not in a git repository."""
|
|
62
|
+
from subprocess import CalledProcessError
|
|
63
|
+
|
|
64
|
+
mock_run.side_effect = CalledProcessError(128, "git")
|
|
65
|
+
|
|
66
|
+
result = get_git_remote_url()
|
|
67
|
+
assert result is None
|
|
68
|
+
|
|
69
|
+
@mock.patch("subprocess.run")
|
|
70
|
+
def test_get_remote_url_no_remote(self, mock_run):
|
|
71
|
+
"""Test when no remote origin exists."""
|
|
72
|
+
from subprocess import CalledProcessError
|
|
73
|
+
|
|
74
|
+
mock_run.side_effect = [
|
|
75
|
+
mock.Mock(returncode=0), # git rev-parse --git-dir
|
|
76
|
+
CalledProcessError(1, "git"), # git config fails
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
result = get_git_remote_url()
|
|
80
|
+
assert result is None
|
|
81
|
+
|
|
82
|
+
@mock.patch("subprocess.run")
|
|
83
|
+
def test_get_remote_url_empty(self, mock_run):
|
|
84
|
+
"""Test when remote URL is empty."""
|
|
85
|
+
mock_run.side_effect = [
|
|
86
|
+
mock.Mock(returncode=0),
|
|
87
|
+
mock.Mock(returncode=0, stdout=""),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
result = get_git_remote_url()
|
|
91
|
+
assert result is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestGetGitInfo:
|
|
95
|
+
"""Test getting comprehensive git info."""
|
|
96
|
+
|
|
97
|
+
@mock.patch("hud.cli.utils.git.get_git_remote_url")
|
|
98
|
+
@mock.patch("subprocess.run")
|
|
99
|
+
def test_get_git_info_success(self, mock_run, mock_get_url):
|
|
100
|
+
"""Test successfully getting all git info."""
|
|
101
|
+
mock_get_url.return_value = "https://github.com/user/repo"
|
|
102
|
+
mock_run.side_effect = [
|
|
103
|
+
mock.Mock(returncode=0, stdout="main\n"), # branch
|
|
104
|
+
mock.Mock(returncode=0, stdout="abc1234\n"), # commit
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
result = get_git_info()
|
|
108
|
+
|
|
109
|
+
assert result["remote_url"] == "https://github.com/user/repo"
|
|
110
|
+
assert result["branch"] == "main"
|
|
111
|
+
assert result["commit"] == "abc1234"
|
|
112
|
+
|
|
113
|
+
@mock.patch("hud.cli.utils.git.get_git_remote_url")
|
|
114
|
+
@mock.patch("subprocess.run")
|
|
115
|
+
def test_get_git_info_no_remote(self, mock_run, mock_get_url):
|
|
116
|
+
"""Test git info when no remote exists."""
|
|
117
|
+
mock_get_url.return_value = None
|
|
118
|
+
mock_run.side_effect = [
|
|
119
|
+
mock.Mock(returncode=0, stdout="feature-branch\n"),
|
|
120
|
+
mock.Mock(returncode=0, stdout="def5678\n"),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
result = get_git_info()
|
|
124
|
+
|
|
125
|
+
assert result["remote_url"] is None
|
|
126
|
+
assert result["branch"] == "feature-branch"
|
|
127
|
+
assert result["commit"] == "def5678"
|
|
128
|
+
|
|
129
|
+
@mock.patch("hud.cli.utils.git.get_git_remote_url")
|
|
130
|
+
@mock.patch("subprocess.run")
|
|
131
|
+
def test_get_git_info_subprocess_error(self, mock_run, mock_get_url):
|
|
132
|
+
"""Test git info when subprocess fails."""
|
|
133
|
+
from subprocess import CalledProcessError
|
|
134
|
+
|
|
135
|
+
mock_get_url.return_value = "https://github.com/user/repo"
|
|
136
|
+
mock_run.side_effect = CalledProcessError(1, "git")
|
|
137
|
+
|
|
138
|
+
result = get_git_info()
|
|
139
|
+
|
|
140
|
+
assert result["remote_url"] == "https://github.com/user/repo"
|
|
141
|
+
assert "branch" not in result
|
|
142
|
+
assert "commit" not in result
|
|
@@ -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.clients.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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from hud.cli.utils.package_runner import run_package_as_mcp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
@mock.patch("hud.cli.utils.package_runner.FastMCP")
|
|
13
|
+
async def test_run_package_as_external_command(MockFastMCP):
|
|
14
|
+
proxy = mock.AsyncMock()
|
|
15
|
+
MockFastMCP.as_proxy.return_value = proxy
|
|
16
|
+
await run_package_as_mcp(["python", "-m", "server"], transport="http", port=9999)
|
|
17
|
+
assert proxy.run_async.awaited
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.asyncio
|
|
21
|
+
@mock.patch("hud.cli.utils.package_runner.importlib.import_module")
|
|
22
|
+
async def test_run_package_import_module(mock_import):
|
|
23
|
+
server = SimpleNamespace(name="test", run_async=mock.AsyncMock())
|
|
24
|
+
mod = SimpleNamespace(mcp=server)
|
|
25
|
+
mock_import.return_value = mod
|
|
26
|
+
await run_package_as_mcp("module_name", transport="stdio")
|
|
27
|
+
assert server.run_async.awaited
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
@mock.patch("hud.cli.utils.package_runner.importlib.import_module")
|
|
32
|
+
async def test_run_package_import_missing_attr(mock_import):
|
|
33
|
+
mock_import.return_value = SimpleNamespace()
|
|
34
|
+
with pytest.raises(SystemExit):
|
|
35
|
+
await run_package_as_mcp("module_name", transport="stdio")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from hud.cli.utils.registry import (
|
|
6
|
+
extract_digest_from_image,
|
|
7
|
+
extract_name_and_tag,
|
|
8
|
+
list_registry_entries,
|
|
9
|
+
load_from_registry,
|
|
10
|
+
save_to_registry,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_extract_digest_from_image_variants():
|
|
18
|
+
assert extract_digest_from_image("repo/name@sha256:abcdef1234567890") == "abcdef123456"
|
|
19
|
+
assert extract_digest_from_image("sha256:deadbeefcafebabe") == "deadbeefcafe"
|
|
20
|
+
assert extract_digest_from_image("org/name:tag") == "tag"
|
|
21
|
+
assert extract_digest_from_image("org/name") == "latest"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_extract_name_and_tag():
|
|
25
|
+
assert extract_name_and_tag("docker.io/hudpython/test_init:latest@sha256:abc") == (
|
|
26
|
+
"hudpython/test_init",
|
|
27
|
+
"latest",
|
|
28
|
+
)
|
|
29
|
+
assert extract_name_and_tag("myorg/myenv:v1.0") == ("myorg/myenv", "v1.0")
|
|
30
|
+
assert extract_name_and_tag("myorg/myenv") == ("myorg/myenv", "latest")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_save_load_list_registry(tmp_path: Path, monkeypatch):
|
|
34
|
+
# Redirect registry dir to temp
|
|
35
|
+
from hud.cli.utils import registry as mod
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(mod, "get_registry_dir", lambda: tmp_path)
|
|
38
|
+
|
|
39
|
+
data = {"image": "org/name:tag", "build": {"version": "0.1.0"}}
|
|
40
|
+
saved = save_to_registry(data, "org/name:tag@sha256:abcdef0123456789", verbose=True)
|
|
41
|
+
assert saved is not None and saved.exists()
|
|
42
|
+
|
|
43
|
+
# Digest directory was created
|
|
44
|
+
entries = list_registry_entries()
|
|
45
|
+
assert len(entries) == 1
|
|
46
|
+
|
|
47
|
+
digest, _ = entries[0]
|
|
48
|
+
loaded = load_from_registry(digest)
|
|
49
|
+
assert loaded and loaded.get("image") == "org/name:tag"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hud.cli.utils.remote_runner import run_remote_server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_run_remote_server_requires_api_key(monkeypatch):
|
|
9
|
+
# Ensure settings.api_key is None and no api_key provided
|
|
10
|
+
from hud.cli.utils import remote_runner as mod
|
|
11
|
+
|
|
12
|
+
monkeypatch.setattr(mod.settings, "api_key", None, raising=True)
|
|
13
|
+
|
|
14
|
+
with pytest.raises(SystemExit) as exc:
|
|
15
|
+
run_remote_server(
|
|
16
|
+
image="img:latest",
|
|
17
|
+
docker_args=[],
|
|
18
|
+
transport="stdio",
|
|
19
|
+
port=8765,
|
|
20
|
+
url="https://api.example.com/mcp",
|
|
21
|
+
api_key=None,
|
|
22
|
+
run_id=None,
|
|
23
|
+
verbose=False,
|
|
24
|
+
)
|
|
25
|
+
assert exc.value.code == 1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hud.cli.utils.remote_runner import build_remote_headers, parse_env_vars, parse_headers
|
|
8
|
+
from hud.cli.utils.runner import run_mcp_server
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_parse_headers_and_env_vars():
|
|
12
|
+
assert parse_headers(["A:B", "C=D"]) == {"A": "B", "C": "D"}
|
|
13
|
+
assert parse_env_vars(["API_KEY=xxx"]) == {"Env-Api-Key": "xxx"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_build_remote_headers_combines():
|
|
17
|
+
headers = build_remote_headers(
|
|
18
|
+
image="img:latest", env_args=["X=1"], header_args=["H:V"], api_key="k", run_id="r"
|
|
19
|
+
)
|
|
20
|
+
assert headers["Mcp-Image"] == "img:latest"
|
|
21
|
+
assert headers["Authorization"].startswith("Bearer ")
|
|
22
|
+
assert headers["Run-Id"] == "r"
|
|
23
|
+
assert headers["Env-X"] == "1"
|
|
24
|
+
assert headers["H"] == "V"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mock.patch("hud.cli.utils.runner.run_stdio_server")
|
|
28
|
+
def test_run_mcp_server_stdio(mock_stdio):
|
|
29
|
+
run_mcp_server("img", [], "stdio", 8765, verbose=False, interactive=False)
|
|
30
|
+
assert mock_stdio.called
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_run_mcp_server_stdio_interactive_fails():
|
|
34
|
+
with pytest.raises(SystemExit):
|
|
35
|
+
run_mcp_server("img", [], "stdio", 8765, verbose=False, interactive=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mock.patch("hud.cli.utils.runner.run_http_server")
|
|
39
|
+
def test_run_mcp_server_http(mock_http):
|
|
40
|
+
run_mcp_server("img", [], "http", 8765, verbose=True, interactive=False)
|
|
41
|
+
assert mock_http.called
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mock.patch("hud.cli.utils.runner.run_http_server_interactive")
|
|
45
|
+
def test_run_mcp_server_http_interactive(mock_http_int):
|
|
46
|
+
run_mcp_server("img", [], "http", 8765, verbose=False, interactive=True)
|
|
47
|
+
assert mock_http_int.called
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_run_mcp_server_unknown():
|
|
51
|
+
with pytest.raises(SystemExit):
|
|
52
|
+
run_mcp_server("img", [], "bad", 8765, verbose=False)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from hud.cli.utils.source_hash import compute_source_hash, list_source_files
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_source_hash_changes_with_content(tmp_path: Path):
|
|
12
|
+
env = tmp_path / "env"
|
|
13
|
+
env.mkdir()
|
|
14
|
+
(env / "Dockerfile").write_text("FROM python:3.11")
|
|
15
|
+
(env / "pyproject.toml").write_text("[tool.hud]\n")
|
|
16
|
+
(env / "server").mkdir()
|
|
17
|
+
(env / "server" / "main.py").write_text("print('hi')\n")
|
|
18
|
+
|
|
19
|
+
h1 = compute_source_hash(env)
|
|
20
|
+
# Change file content
|
|
21
|
+
(env / "server" / "main.py").write_text("print('bye')\n")
|
|
22
|
+
h2 = compute_source_hash(env)
|
|
23
|
+
assert h1 != h2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_list_source_files_sorted(tmp_path: Path):
|
|
27
|
+
env = tmp_path / "env"
|
|
28
|
+
env.mkdir()
|
|
29
|
+
(env / "Dockerfile").write_text("FROM python:3.11")
|
|
30
|
+
(env / "environment").mkdir()
|
|
31
|
+
(env / "environment" / "a.py").write_text("a")
|
|
32
|
+
(env / "environment" / "b.py").write_text("b")
|
|
33
|
+
|
|
34
|
+
files = list_source_files(env)
|
|
35
|
+
rels = [str(p.resolve().relative_to(env)).replace("\\", "/") for p in files]
|
|
36
|
+
assert rels == ["Dockerfile", "environment/a.py", "environment/b.py"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from hud.cli.utils.tasks import find_tasks_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@patch("pathlib.Path.cwd")
|
|
12
|
+
def test_find_tasks_file_with_arg(mock_cwd):
|
|
13
|
+
"""Test that when a tasks file is provided, it's returned as-is."""
|
|
14
|
+
assert find_tasks_file("some/path.json") == "some/path.json"
|
|
15
|
+
mock_cwd.assert_not_called()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@patch("pathlib.Path.cwd")
|
|
19
|
+
def test_find_tasks_file_no_files(mock_cwd):
|
|
20
|
+
"""Test that FileNotFoundError is raised when no task files exist."""
|
|
21
|
+
mock_path = MagicMock(spec=Path)
|
|
22
|
+
mock_path.glob.return_value = []
|
|
23
|
+
mock_cwd.return_value = mock_path
|
|
24
|
+
|
|
25
|
+
with pytest.raises(FileNotFoundError, match="No task JSON or JSONL files found"):
|
|
26
|
+
find_tasks_file(None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@patch("hud.cli.utils.tasks.hud_console")
|
|
30
|
+
@patch("pathlib.Path.cwd")
|
|
31
|
+
def test_find_tasks_file_single_file(mock_cwd, mock_console):
|
|
32
|
+
"""Test that when only one file exists, it's returned without prompting."""
|
|
33
|
+
mock_path = MagicMock(spec=Path)
|
|
34
|
+
mock_file = MagicMock(spec=Path)
|
|
35
|
+
mock_file.__str__.return_value = "test.json" # type: ignore
|
|
36
|
+
|
|
37
|
+
def glob_side_effect(pattern):
|
|
38
|
+
if pattern == "*.json":
|
|
39
|
+
return [mock_file]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
mock_path.glob.side_effect = glob_side_effect
|
|
43
|
+
mock_path.__str__.return_value = str(Path.cwd()) # type: ignore
|
|
44
|
+
mock_cwd.return_value = mock_path
|
|
45
|
+
|
|
46
|
+
result = find_tasks_file(None)
|
|
47
|
+
assert result == "test.json"
|
|
48
|
+
mock_console.select.assert_not_called()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@patch("hud.cli.utils.tasks.hud_console")
|
|
52
|
+
@patch("pathlib.Path.cwd")
|
|
53
|
+
def test_find_tasks_file_multiple_files(mock_cwd, mock_console):
|
|
54
|
+
"""Test that when multiple files exist, user is prompted to select one."""
|
|
55
|
+
mock_path = MagicMock(spec=Path)
|
|
56
|
+
mock_file1 = MagicMock(spec=Path)
|
|
57
|
+
mock_file1.__str__.return_value = "test1.json" # type: ignore
|
|
58
|
+
mock_file2 = MagicMock(spec=Path)
|
|
59
|
+
mock_file2.__str__.return_value = "test2.jsonl" # type: ignore
|
|
60
|
+
|
|
61
|
+
def glob_side_effect(pattern):
|
|
62
|
+
if pattern == "*.json":
|
|
63
|
+
return [mock_file1]
|
|
64
|
+
if pattern == "*.jsonl":
|
|
65
|
+
return [mock_file2]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
mock_path.glob.side_effect = glob_side_effect
|
|
69
|
+
mock_path.__str__.return_value = str(Path.cwd()) # type: ignore
|
|
70
|
+
mock_cwd.return_value = mock_path
|
|
71
|
+
mock_console.select.return_value = "test2.jsonl"
|
|
72
|
+
|
|
73
|
+
result = find_tasks_file(None)
|
|
74
|
+
|
|
75
|
+
assert result == "test2.jsonl"
|
|
76
|
+
mock_console.select.assert_called_once()
|
|
77
|
+
call_args = mock_console.select.call_args
|
|
78
|
+
assert call_args[0][0] == "Select a tasks file"
|
|
79
|
+
assert "test1.json" in call_args[1]["choices"]
|
|
80
|
+
assert "test2.jsonl" in call_args[1]["choices"]
|