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.
- hud/agents/base.py +9 -2
- hud/agents/openai_chat_generic.py +15 -3
- hud/agents/tests/test_base.py +15 -0
- hud/agents/tests/test_base_runtime.py +164 -0
- hud/cli/__init__.py +6 -3
- hud/cli/build.py +35 -27
- hud/cli/dev.py +11 -29
- hud/cli/eval.py +61 -61
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +24 -2
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +134 -0
- hud/cli/tests/test_mcp_server.py +8 -7
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/utils/docker.py +120 -1
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +2 -2
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_runner.py +106 -0
- hud/datasets/tests/test_utils.py +228 -0
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_instrumentation.py +207 -0
- hud/server/tests/test_server_extra.py +2 -0
- hud/shared/exceptions.py +35 -4
- hud/shared/hints.py +25 -0
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +31 -23
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/tests/test_async_context.py +242 -0
- hud/telemetry/tests/test_instrument.py +414 -0
- hud/telemetry/tests/test_job.py +609 -0
- hud/telemetry/tests/test_trace.py +183 -5
- hud/tools/computer/settings.py +2 -2
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/types.py +7 -1
- hud/utils/agent_factories.py +1 -3
- hud/utils/mcp.py +1 -1
- hud/utils/tests/test_agent_factories.py +60 -0
- hud/utils/tests/test_mcp.py +4 -6
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tasks.py +187 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/METADATA +47 -48
- {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/RECORD +69 -31
- {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/WHEEL +0 -0
- {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hud.otel.instrumentation import (
|
|
8
|
+
_patch_get_error_type,
|
|
9
|
+
_patch_mcp_instrumentation,
|
|
10
|
+
install_mcp_instrumentation,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_install_mcp_instrumentation_success():
|
|
15
|
+
"""Test successful installation of MCP instrumentation."""
|
|
16
|
+
mock_provider = MagicMock()
|
|
17
|
+
|
|
18
|
+
with (
|
|
19
|
+
patch("opentelemetry.instrumentation.mcp.instrumentation"),
|
|
20
|
+
patch(
|
|
21
|
+
"opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor"
|
|
22
|
+
) as mock_instrumentor_class,
|
|
23
|
+
patch("hud.otel.instrumentation._patch_mcp_instrumentation"),
|
|
24
|
+
):
|
|
25
|
+
mock_instrumentor = MagicMock()
|
|
26
|
+
mock_instrumentor_class.return_value = mock_instrumentor
|
|
27
|
+
|
|
28
|
+
install_mcp_instrumentation(mock_provider)
|
|
29
|
+
|
|
30
|
+
mock_instrumentor.instrument.assert_called_once_with(tracer_provider=mock_provider)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_install_mcp_instrumentation_import_error():
|
|
34
|
+
"""Test installation handles ImportError gracefully."""
|
|
35
|
+
mock_provider = MagicMock()
|
|
36
|
+
|
|
37
|
+
# Mock the import to raise ImportError
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
with patch.dict(sys.modules, {"opentelemetry.instrumentation.mcp.instrumentation": None}):
|
|
41
|
+
# Should not raise
|
|
42
|
+
install_mcp_instrumentation(mock_provider)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_install_mcp_instrumentation_general_exception():
|
|
46
|
+
"""Test installation handles general exceptions gracefully."""
|
|
47
|
+
mock_provider = MagicMock()
|
|
48
|
+
|
|
49
|
+
with (
|
|
50
|
+
patch("opentelemetry.instrumentation.mcp.instrumentation"),
|
|
51
|
+
patch(
|
|
52
|
+
"opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor"
|
|
53
|
+
) as mock_instrumentor_class,
|
|
54
|
+
):
|
|
55
|
+
mock_instrumentor_class.side_effect = Exception("Unexpected error")
|
|
56
|
+
|
|
57
|
+
# Should not raise
|
|
58
|
+
install_mcp_instrumentation(mock_provider)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_patch_mcp_instrumentation_success():
|
|
62
|
+
"""Test successful patching of MCP instrumentation."""
|
|
63
|
+
with (
|
|
64
|
+
patch("opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor") as mock_class,
|
|
65
|
+
patch("hud.otel.instrumentation._patch_get_error_type"),
|
|
66
|
+
):
|
|
67
|
+
mock_class._transport_wrapper = None
|
|
68
|
+
|
|
69
|
+
_patch_mcp_instrumentation()
|
|
70
|
+
|
|
71
|
+
# Should have set the _transport_wrapper
|
|
72
|
+
assert mock_class._transport_wrapper is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_patch_mcp_instrumentation_exception():
|
|
76
|
+
"""Test patching handles exceptions gracefully."""
|
|
77
|
+
with patch(
|
|
78
|
+
"opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor",
|
|
79
|
+
side_effect=Exception("Error"),
|
|
80
|
+
):
|
|
81
|
+
# Should not raise
|
|
82
|
+
_patch_mcp_instrumentation()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_patch_get_error_type_success():
|
|
86
|
+
"""Test successful patching of get_error_type."""
|
|
87
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
88
|
+
mock_mcp_inst.get_error_type = None
|
|
89
|
+
|
|
90
|
+
_patch_get_error_type()
|
|
91
|
+
|
|
92
|
+
# Should have set get_error_type
|
|
93
|
+
assert mock_mcp_inst.get_error_type is not None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_patch_get_error_type_exception():
|
|
97
|
+
"""Test patching get_error_type handles exceptions."""
|
|
98
|
+
with patch(
|
|
99
|
+
"opentelemetry.instrumentation.mcp.instrumentation", side_effect=ImportError("Not found")
|
|
100
|
+
):
|
|
101
|
+
# Should not raise
|
|
102
|
+
_patch_get_error_type()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_patched_get_error_type_valid_4xx():
|
|
106
|
+
"""Test patched get_error_type with valid 4xx status code."""
|
|
107
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
108
|
+
_patch_get_error_type()
|
|
109
|
+
|
|
110
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
111
|
+
|
|
112
|
+
# Test with a valid 4xx error
|
|
113
|
+
result = patched_func("Error 404 not found")
|
|
114
|
+
assert result == "NOT_FOUND"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_patched_get_error_type_valid_5xx():
|
|
118
|
+
"""Test patched get_error_type with valid 5xx status code."""
|
|
119
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
120
|
+
_patch_get_error_type()
|
|
121
|
+
|
|
122
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
123
|
+
|
|
124
|
+
# Test with a valid 5xx error
|
|
125
|
+
result = patched_func("Error 500 internal server error")
|
|
126
|
+
assert result == "INTERNAL_SERVER_ERROR"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_patched_get_error_type_invalid_status():
|
|
130
|
+
"""Test patched get_error_type with invalid status code."""
|
|
131
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
132
|
+
_patch_get_error_type()
|
|
133
|
+
|
|
134
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
135
|
+
|
|
136
|
+
# Test with an invalid HTTP status code (e.g., 499 doesn't exist in HTTPStatus)
|
|
137
|
+
result = patched_func("Error 499 custom error")
|
|
138
|
+
# Should return the name even if it's not a standard HTTPStatus
|
|
139
|
+
assert result is None or isinstance(result, str)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_patched_get_error_type_no_status():
|
|
143
|
+
"""Test patched get_error_type with no status code."""
|
|
144
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
145
|
+
_patch_get_error_type()
|
|
146
|
+
|
|
147
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
148
|
+
|
|
149
|
+
result = patched_func("Error message without status code")
|
|
150
|
+
assert result is None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_patched_get_error_type_non_string():
|
|
154
|
+
"""Test patched get_error_type with non-string input."""
|
|
155
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
156
|
+
_patch_get_error_type()
|
|
157
|
+
|
|
158
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
159
|
+
|
|
160
|
+
result = patched_func(None)
|
|
161
|
+
assert result is None
|
|
162
|
+
|
|
163
|
+
result = patched_func(123)
|
|
164
|
+
assert result is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_patched_get_error_type_3xx_ignored():
|
|
168
|
+
"""Test patched get_error_type ignores 3xx codes."""
|
|
169
|
+
with patch("opentelemetry.instrumentation.mcp.instrumentation") as mock_mcp_inst:
|
|
170
|
+
_patch_get_error_type()
|
|
171
|
+
|
|
172
|
+
patched_func = mock_mcp_inst.get_error_type
|
|
173
|
+
|
|
174
|
+
result = patched_func("Error 301 moved")
|
|
175
|
+
assert result is None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_transport_wrapper_three_values():
|
|
180
|
+
"""Test transport wrapper handles 3-value tuple."""
|
|
181
|
+
with (
|
|
182
|
+
patch("opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor") as mock_class,
|
|
183
|
+
patch("hud.otel.instrumentation._patch_get_error_type"),
|
|
184
|
+
):
|
|
185
|
+
mock_class._transport_wrapper = None
|
|
186
|
+
|
|
187
|
+
_patch_mcp_instrumentation()
|
|
188
|
+
|
|
189
|
+
# Get the patched wrapper
|
|
190
|
+
wrapper_func = mock_class._transport_wrapper
|
|
191
|
+
assert wrapper_func is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_transport_wrapper_two_values():
|
|
196
|
+
"""Test transport wrapper handles 2-value tuple."""
|
|
197
|
+
with (
|
|
198
|
+
patch("opentelemetry.instrumentation.mcp.instrumentation.McpInstrumentor") as mock_class,
|
|
199
|
+
patch("hud.otel.instrumentation._patch_get_error_type"),
|
|
200
|
+
):
|
|
201
|
+
mock_class._transport_wrapper = None
|
|
202
|
+
|
|
203
|
+
_patch_mcp_instrumentation()
|
|
204
|
+
|
|
205
|
+
# Get the patched wrapper
|
|
206
|
+
wrapper_func = mock_class._transport_wrapper
|
|
207
|
+
assert wrapper_func is not None
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
+
import sys
|
|
5
6
|
from contextlib import asynccontextmanager, suppress
|
|
6
7
|
|
|
7
8
|
import anyio
|
|
@@ -98,6 +99,7 @@ async def test_last_shutdown_handler_wins(patch_stdio):
|
|
|
98
99
|
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="asyncio.add_signal_handler is Unix-only")
|
|
101
103
|
def test__run_with_sigterm_registers_handlers_when_enabled(monkeypatch: pytest.MonkeyPatch):
|
|
102
104
|
"""
|
|
103
105
|
Verify that _run_with_sigterm attempts to register SIGTERM/SIGINT handlers
|
hud/shared/exceptions.py
CHANGED
|
@@ -185,11 +185,42 @@ class HudRequestError(HudException):
|
|
|
185
185
|
self.response_text = response_text
|
|
186
186
|
self.response_headers = response_headers
|
|
187
187
|
# Compute default hints from status code if none provided
|
|
188
|
-
if hints is None and status_code in (401, 403, 429):
|
|
188
|
+
if hints is None and status_code in (401, 402, 403, 429):
|
|
189
189
|
try:
|
|
190
|
-
from hud.shared.hints import
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
from hud.shared.hints import ( # type: ignore
|
|
191
|
+
CREDITS_EXHAUSTED,
|
|
192
|
+
HUD_API_KEY_MISSING,
|
|
193
|
+
PRO_PLAN_REQUIRED,
|
|
194
|
+
RATE_LIMIT_HIT,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if status_code == 402:
|
|
198
|
+
hints = [CREDITS_EXHAUSTED]
|
|
199
|
+
elif status_code == 403:
|
|
200
|
+
# Default 403 to auth unless the message clearly indicates Pro plan
|
|
201
|
+
combined_text = (message or "").lower()
|
|
202
|
+
try:
|
|
203
|
+
if response_text:
|
|
204
|
+
combined_text += "\n" + str(response_text).lower()
|
|
205
|
+
except Exception: # noqa: S110
|
|
206
|
+
pass
|
|
207
|
+
try:
|
|
208
|
+
if response_json and isinstance(response_json, dict):
|
|
209
|
+
detail = response_json.get("detail")
|
|
210
|
+
if isinstance(detail, str):
|
|
211
|
+
combined_text += "\n" + detail.lower()
|
|
212
|
+
except Exception: # noqa: S110
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
mentions_pro = (
|
|
216
|
+
"pro plan" in combined_text
|
|
217
|
+
or "requires pro" in combined_text
|
|
218
|
+
or "pro mode" in combined_text
|
|
219
|
+
or combined_text.strip().startswith("pro ")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
hints = [PRO_PLAN_REQUIRED] if mentions_pro else [HUD_API_KEY_MISSING]
|
|
223
|
+
elif status_code == 401:
|
|
193
224
|
hints = [HUD_API_KEY_MISSING]
|
|
194
225
|
elif status_code == 429:
|
|
195
226
|
hints = [RATE_LIMIT_HIT]
|
hud/shared/hints.py
CHANGED
|
@@ -61,6 +61,31 @@ RATE_LIMIT_HIT = Hint(
|
|
|
61
61
|
context=["network"],
|
|
62
62
|
)
|
|
63
63
|
|
|
64
|
+
# Billing / plan upgrade
|
|
65
|
+
PRO_PLAN_REQUIRED = Hint(
|
|
66
|
+
title="Pro plan required",
|
|
67
|
+
message="This feature requires Pro.",
|
|
68
|
+
tips=[
|
|
69
|
+
"Upgrade your plan to continue",
|
|
70
|
+
],
|
|
71
|
+
docs_url="https://hud.so/project/billing",
|
|
72
|
+
command_examples=None,
|
|
73
|
+
code="PRO_PLAN_REQUIRED",
|
|
74
|
+
context=["billing", "plan"],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
CREDITS_EXHAUSTED = Hint(
|
|
78
|
+
title="Credits exhausted",
|
|
79
|
+
message="Your credits are exhausted.",
|
|
80
|
+
tips=[
|
|
81
|
+
"Top up credits or upgrade your plan",
|
|
82
|
+
],
|
|
83
|
+
docs_url="https://hud.so/project/billing",
|
|
84
|
+
command_examples=None,
|
|
85
|
+
code="CREDITS_EXHAUSTED",
|
|
86
|
+
context=["billing", "credits"],
|
|
87
|
+
)
|
|
88
|
+
|
|
64
89
|
TOOL_NOT_FOUND = Hint(
|
|
65
90
|
title="Tool not found",
|
|
66
91
|
message="Requested tool doesn't exist.",
|
hud/shared/requests.py
CHANGED
|
@@ -18,7 +18,11 @@ from hud.shared.exceptions import (
|
|
|
18
18
|
HudRequestError,
|
|
19
19
|
HudTimeoutError,
|
|
20
20
|
)
|
|
21
|
-
from hud.shared.hints import
|
|
21
|
+
from hud.shared.hints import (
|
|
22
|
+
CREDITS_EXHAUSTED,
|
|
23
|
+
HUD_API_KEY_MISSING,
|
|
24
|
+
RATE_LIMIT_HIT,
|
|
25
|
+
)
|
|
22
26
|
|
|
23
27
|
# Set up logger
|
|
24
28
|
logger = logging.getLogger("hud.http")
|
|
@@ -137,9 +141,13 @@ async def make_request(
|
|
|
137
141
|
raise HudTimeoutError(f"Request timed out: {e!s}") from None
|
|
138
142
|
except httpx.HTTPStatusError as e:
|
|
139
143
|
err = HudRequestError.from_httpx_error(e)
|
|
140
|
-
|
|
144
|
+
code = getattr(err, "status_code", None)
|
|
145
|
+
if code == 429 and RATE_LIMIT_HIT not in err.hints:
|
|
141
146
|
logger.debug("Attaching RATE_LIMIT hint to 429 error")
|
|
142
147
|
err.hints.append(RATE_LIMIT_HIT)
|
|
148
|
+
elif code == 402 and CREDITS_EXHAUSTED not in err.hints:
|
|
149
|
+
logger.debug("Attaching CREDITS_EXHAUSTED hint to 402 error")
|
|
150
|
+
err.hints.append(CREDITS_EXHAUSTED)
|
|
143
151
|
raise err from None
|
|
144
152
|
except httpx.RequestError as e:
|
|
145
153
|
if attempt <= max_retries:
|
|
@@ -234,9 +242,13 @@ def make_request_sync(
|
|
|
234
242
|
raise HudTimeoutError(f"Request timed out: {e!s}") from None
|
|
235
243
|
except httpx.HTTPStatusError as e:
|
|
236
244
|
err = HudRequestError.from_httpx_error(e)
|
|
237
|
-
|
|
245
|
+
code = getattr(err, "status_code", None)
|
|
246
|
+
if code == 429 and RATE_LIMIT_HIT not in err.hints:
|
|
238
247
|
logger.debug("Attaching RATE_LIMIT hint to 429 error")
|
|
239
248
|
err.hints.append(RATE_LIMIT_HIT)
|
|
249
|
+
elif code == 402 and CREDITS_EXHAUSTED not in err.hints:
|
|
250
|
+
logger.debug("Attaching CREDITS_EXHAUSTED hint to 402 error")
|
|
251
|
+
err.hints.append(CREDITS_EXHAUSTED)
|
|
240
252
|
raise err from None
|
|
241
253
|
except httpx.RequestError as e:
|
|
242
254
|
if attempt <= max_retries:
|
|
@@ -17,7 +17,6 @@ from hud.shared.exceptions import (
|
|
|
17
17
|
HudClientError,
|
|
18
18
|
HudConfigError,
|
|
19
19
|
HudException,
|
|
20
|
-
HudMCPError,
|
|
21
20
|
HudRateLimitError,
|
|
22
21
|
HudRequestError,
|
|
23
22
|
HudTimeoutError,
|
|
@@ -27,6 +26,7 @@ from hud.shared.hints import (
|
|
|
27
26
|
CLIENT_NOT_INITIALIZED,
|
|
28
27
|
HUD_API_KEY_MISSING,
|
|
29
28
|
INVALID_CONFIG,
|
|
29
|
+
PRO_PLAN_REQUIRED,
|
|
30
30
|
RATE_LIMIT_HIT,
|
|
31
31
|
TOOL_NOT_FOUND,
|
|
32
32
|
)
|
|
@@ -157,25 +157,23 @@ class TestHudExceptionAutoConversion:
|
|
|
157
157
|
assert str(exc_info.value) == "Async operation timed out"
|
|
158
158
|
|
|
159
159
|
def test_generic_error_remains_hudexception(self):
|
|
160
|
-
"""
|
|
160
|
+
"""Uncategorized errors become base HudException with original message."""
|
|
161
161
|
try:
|
|
162
162
|
raise ValueError("Some random error")
|
|
163
163
|
except Exception as e:
|
|
164
164
|
with pytest.raises(HudException) as exc_info:
|
|
165
165
|
raise HudException from e
|
|
166
|
-
|
|
167
|
-
# Should be base HudException, not a subclass
|
|
166
|
+
# Should be base HudException, not subclass
|
|
168
167
|
assert type(exc_info.value) is HudException
|
|
169
|
-
assert exc_info.value
|
|
168
|
+
assert str(exc_info.value) == "Some random error"
|
|
170
169
|
|
|
171
170
|
def test_custom_message_override(self):
|
|
172
|
-
"""
|
|
171
|
+
"""Custom message should be used for categorized errors."""
|
|
173
172
|
try:
|
|
174
|
-
raise ValueError("
|
|
173
|
+
raise ValueError("Client not initialized - call initialize() first")
|
|
175
174
|
except Exception as e:
|
|
176
|
-
with pytest.raises(
|
|
175
|
+
with pytest.raises(HudClientError) as exc_info:
|
|
177
176
|
raise HudException("Custom error message") from e
|
|
178
|
-
|
|
179
177
|
assert str(exc_info.value) == "Custom error message"
|
|
180
178
|
|
|
181
179
|
def test_already_hud_exception_passthrough(self):
|
|
@@ -205,6 +203,22 @@ class TestHudRequestError:
|
|
|
205
203
|
error = HudRequestError("Forbidden", status_code=403)
|
|
206
204
|
assert HUD_API_KEY_MISSING in error.hints
|
|
207
205
|
|
|
206
|
+
def test_403_pro_plan_message_sets_pro_hint(self):
|
|
207
|
+
"""403 with Pro wording should map to PRO_PLAN_REQUIRED, not auth."""
|
|
208
|
+
error = HudRequestError("Feature requires Pro plan", status_code=403)
|
|
209
|
+
assert PRO_PLAN_REQUIRED in error.hints
|
|
210
|
+
assert HUD_API_KEY_MISSING not in error.hints
|
|
211
|
+
|
|
212
|
+
def test_403_pro_plan_detail_sets_pro_hint(self):
|
|
213
|
+
"""403 with detail indicating Pro should map to PRO_PLAN_REQUIRED."""
|
|
214
|
+
error = HudRequestError(
|
|
215
|
+
"Forbidden",
|
|
216
|
+
status_code=403,
|
|
217
|
+
response_json={"detail": "Requires Pro plan"},
|
|
218
|
+
)
|
|
219
|
+
assert PRO_PLAN_REQUIRED in error.hints
|
|
220
|
+
assert HUD_API_KEY_MISSING not in error.hints
|
|
221
|
+
|
|
208
222
|
def test_429_adds_rate_limit_hint(self):
|
|
209
223
|
"""Test that 429 status adds rate limit hint."""
|
|
210
224
|
error = HudRequestError("Too Many Requests", status_code=429)
|
|
@@ -244,23 +258,19 @@ class TestMCPErrorHandling:
|
|
|
244
258
|
@pytest.mark.asyncio
|
|
245
259
|
async def test_mcp_error_handling(self):
|
|
246
260
|
"""Test that McpError is handled appropriately."""
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
class McpError(Exception):
|
|
250
|
-
pass
|
|
251
|
-
|
|
252
|
-
# Create a mock MCP error
|
|
253
|
-
mcp_error = McpError("MCP protocol error: Unknown method")
|
|
261
|
+
# Create a dynamic class named "McpError" to trigger name-based detection
|
|
262
|
+
McpError = type("McpError", (Exception,), {})
|
|
254
263
|
|
|
255
264
|
try:
|
|
256
|
-
raise
|
|
265
|
+
raise McpError("MCP protocol error: Unknown method")
|
|
257
266
|
except Exception as e:
|
|
258
267
|
# This would typically be caught in the client code
|
|
259
268
|
# and re-raised as HudException
|
|
260
|
-
with pytest.raises(
|
|
269
|
+
with pytest.raises(HudException) as exc_info:
|
|
261
270
|
raise HudException from e
|
|
262
271
|
|
|
263
272
|
assert "MCP protocol error" in str(exc_info.value)
|
|
273
|
+
assert "MCP protocol error" in str(exc_info.value)
|
|
264
274
|
|
|
265
275
|
def test_mcp_tool_error_result(self):
|
|
266
276
|
"""Test handling of MCP tool execution errors (isError: true)."""
|
|
@@ -353,6 +363,7 @@ class TestExceptionRendering:
|
|
|
353
363
|
assert len(error.hints) == 1
|
|
354
364
|
assert error.hints[0] == HUD_API_KEY_MISSING
|
|
355
365
|
assert error.hints[0].title == "HUD API key required"
|
|
366
|
+
# Hint copy evolved; keep the assertion robust to minor copy changes
|
|
356
367
|
assert "Set HUD_API_KEY" in error.hints[0].tips[0]
|
|
357
368
|
|
|
358
369
|
def test_exception_type_preservation(self):
|
|
@@ -397,16 +408,13 @@ class TestEdgeCases:
|
|
|
397
408
|
assert type(error) is HudException
|
|
398
409
|
|
|
399
410
|
def test_empty_error_message(self):
|
|
400
|
-
"""
|
|
411
|
+
"""Empty message still results in a HudException instance."""
|
|
401
412
|
try:
|
|
402
413
|
raise ValueError("")
|
|
403
414
|
except Exception as e:
|
|
404
|
-
with pytest.raises(HudException)
|
|
415
|
+
with pytest.raises(HudException):
|
|
405
416
|
raise HudException from e
|
|
406
417
|
|
|
407
|
-
# Should still have some message
|
|
408
|
-
assert str(exc_info.value) != ""
|
|
409
|
-
|
|
410
418
|
def test_circular_exception_chain(self):
|
|
411
419
|
"""Test that we don't create circular exception chains."""
|
|
412
420
|
original = HudAuthenticationError("Original")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from hud.shared.hints import (
|
|
6
|
+
CLIENT_NOT_INITIALIZED,
|
|
7
|
+
ENV_VAR_MISSING,
|
|
8
|
+
HUD_API_KEY_MISSING,
|
|
9
|
+
INVALID_CONFIG,
|
|
10
|
+
MCP_SERVER_ERROR,
|
|
11
|
+
RATE_LIMIT_HIT,
|
|
12
|
+
TOOL_NOT_FOUND,
|
|
13
|
+
Hint,
|
|
14
|
+
render_hints,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_hint_objects_basic():
|
|
19
|
+
assert HUD_API_KEY_MISSING.title and isinstance(HUD_API_KEY_MISSING.tips, list)
|
|
20
|
+
assert RATE_LIMIT_HIT.code == "RATE_LIMIT"
|
|
21
|
+
assert TOOL_NOT_FOUND.title.startswith("Tool")
|
|
22
|
+
assert CLIENT_NOT_INITIALIZED.message
|
|
23
|
+
assert ENV_VAR_MISSING.command_examples is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_all_hint_constants():
|
|
27
|
+
"""Test that all predefined hint constants have required fields."""
|
|
28
|
+
hints = [
|
|
29
|
+
HUD_API_KEY_MISSING,
|
|
30
|
+
RATE_LIMIT_HIT,
|
|
31
|
+
TOOL_NOT_FOUND,
|
|
32
|
+
CLIENT_NOT_INITIALIZED,
|
|
33
|
+
INVALID_CONFIG,
|
|
34
|
+
ENV_VAR_MISSING,
|
|
35
|
+
MCP_SERVER_ERROR,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
for hint in hints:
|
|
39
|
+
assert hint.title
|
|
40
|
+
assert hint.message
|
|
41
|
+
assert hint.code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hint_creation():
|
|
45
|
+
"""Test creating a custom Hint."""
|
|
46
|
+
hint = Hint(
|
|
47
|
+
title="Test Hint",
|
|
48
|
+
message="This is a test",
|
|
49
|
+
tips=["Tip 1", "Tip 2"],
|
|
50
|
+
docs_url="https://example.com",
|
|
51
|
+
command_examples=["command 1"],
|
|
52
|
+
code="TEST_CODE",
|
|
53
|
+
context=["test", "custom"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
assert hint.title == "Test Hint"
|
|
57
|
+
assert hint.message == "This is a test"
|
|
58
|
+
assert hint.tips and len(hint.tips) == 2
|
|
59
|
+
assert hint.docs_url == "https://example.com"
|
|
60
|
+
assert hint.command_examples and len(hint.command_examples) == 1
|
|
61
|
+
assert hint.code == "TEST_CODE"
|
|
62
|
+
assert hint.context and "test" in hint.context
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_hint_minimal():
|
|
66
|
+
"""Test creating a minimal Hint with only required fields."""
|
|
67
|
+
hint = Hint(title="Minimal", message="Just basics")
|
|
68
|
+
|
|
69
|
+
assert hint.title == "Minimal"
|
|
70
|
+
assert hint.message == "Just basics"
|
|
71
|
+
assert hint.tips is None
|
|
72
|
+
assert hint.docs_url is None
|
|
73
|
+
assert hint.command_examples is None
|
|
74
|
+
assert hint.code is None
|
|
75
|
+
assert hint.context is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_render_hints_none():
|
|
79
|
+
"""Test that render_hints handles None gracefully."""
|
|
80
|
+
# Should not raise
|
|
81
|
+
render_hints(None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_render_hints_empty_list():
|
|
85
|
+
"""Test that render_hints handles empty list gracefully."""
|
|
86
|
+
# Should not raise
|
|
87
|
+
render_hints([])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
91
|
+
def test_render_hints_with_tips(mock_console):
|
|
92
|
+
"""Test rendering hints with tips."""
|
|
93
|
+
render_hints([HUD_API_KEY_MISSING])
|
|
94
|
+
|
|
95
|
+
# Should call warning for title/message
|
|
96
|
+
mock_console.warning.assert_called()
|
|
97
|
+
# Should call info for tips
|
|
98
|
+
assert mock_console.info.call_count >= 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
102
|
+
def test_render_hints_with_command_examples(mock_console):
|
|
103
|
+
"""Test rendering hints with command examples."""
|
|
104
|
+
render_hints([ENV_VAR_MISSING])
|
|
105
|
+
|
|
106
|
+
# Should call command_example
|
|
107
|
+
mock_console.command_example.assert_called()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
111
|
+
def test_render_hints_with_docs_url(mock_console):
|
|
112
|
+
"""Test rendering hints with documentation URL."""
|
|
113
|
+
hint = Hint(
|
|
114
|
+
title="Test",
|
|
115
|
+
message="Test message",
|
|
116
|
+
docs_url="https://docs.example.com",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
render_hints([hint])
|
|
120
|
+
|
|
121
|
+
# Should call link for docs URL
|
|
122
|
+
mock_console.link.assert_called_with("https://docs.example.com")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
126
|
+
def test_render_hints_same_title_and_message(mock_console):
|
|
127
|
+
"""Test rendering hints when title equals message."""
|
|
128
|
+
hint = Hint(title="Same", message="Same")
|
|
129
|
+
|
|
130
|
+
render_hints([hint])
|
|
131
|
+
|
|
132
|
+
# Should only call warning once with just the message
|
|
133
|
+
mock_console.warning.assert_called_once_with("Same")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
137
|
+
def test_render_hints_different_title_and_message(mock_console):
|
|
138
|
+
"""Test rendering hints when title differs from message."""
|
|
139
|
+
hint = Hint(title="Title", message="Different message")
|
|
140
|
+
|
|
141
|
+
render_hints([hint])
|
|
142
|
+
|
|
143
|
+
# Should call warning with both title and message
|
|
144
|
+
mock_console.warning.assert_called_once()
|
|
145
|
+
call_args = mock_console.warning.call_args[0][0]
|
|
146
|
+
assert "Title" in call_args
|
|
147
|
+
assert "Different message" in call_args
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_render_hints_with_custom_design():
|
|
151
|
+
"""Test rendering hints with custom design object."""
|
|
152
|
+
custom_design = MagicMock()
|
|
153
|
+
|
|
154
|
+
hint = Hint(title="Test", message="Message")
|
|
155
|
+
# Should not raise when custom design is provided
|
|
156
|
+
render_hints([hint], design=custom_design)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@patch("hud.utils.hud_console.hud_console")
|
|
160
|
+
def test_render_hints_handles_exception(mock_console):
|
|
161
|
+
"""Test that render_hints handles exceptions gracefully."""
|
|
162
|
+
mock_console.warning.side_effect = Exception("Test error")
|
|
163
|
+
|
|
164
|
+
hint = Hint(title="Test", message="Message")
|
|
165
|
+
|
|
166
|
+
# Should not raise, just log warning
|
|
167
|
+
render_hints([hint])
|