hud-python 0.4.45__py3-none-any.whl → 0.5.13__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 +70 -5
- hud/agents/base.py +238 -500
- hud/agents/claude.py +236 -247
- hud/agents/gateway.py +42 -0
- hud/agents/gemini.py +264 -0
- hud/agents/gemini_cua.py +324 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +48 -36
- hud/agents/openai.py +282 -296
- hud/agents/{openai_chat_generic.py → openai_chat.py} +63 -33
- hud/agents/operator.py +199 -0
- hud/agents/resolver.py +70 -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 +381 -214
- 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 +377 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_resolver.py +192 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/agents/types.py +148 -0
- hud/cli/__init__.py +493 -546
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +699 -113
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +889 -732
- hud/cli/eval.py +793 -667
- 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/pull.py +1 -1
- hud/cli/push.py +38 -13
- 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 +110 -8
- 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.py +1 -1
- 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 +70 -1
- 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 +45 -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 +326 -0
- hud/datasets/runner.py +198 -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 +52 -0
- hud/environment/connection.py +258 -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 +137 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +835 -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 +263 -0
- hud/environment/scenarios.py +620 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +205 -0
- hud/environment/tests/test_environment.py +593 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +242 -0
- hud/environment/tests/test_scenarios.py +1086 -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 +727 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +187 -0
- hud/eval/manager.py +533 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +372 -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 +291 -0
- hud/eval/types.py +65 -0
- hud/eval/utils.py +194 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +308 -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 +165 -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 +18 -2
- hud/tools/agent.py +223 -0
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +36 -3
- 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_agent_tool.py +355 -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 +194 -56
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +89 -18
- 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.13.dist-info/METADATA +264 -0
- hud_python-0.5.13.dist-info/RECORD +305 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.13.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.13.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -84,7 +84,7 @@ async def test_initialize_runs_once_and_tools_work() -> None:
|
|
|
84
84
|
|
|
85
85
|
async def connect_and_check() -> None:
|
|
86
86
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
87
|
-
client = MCPClient(mcp_config=cfg,
|
|
87
|
+
client = MCPClient(mcp_config=cfg, verbose=False)
|
|
88
88
|
await client.initialize()
|
|
89
89
|
tools = await client.list_tools()
|
|
90
90
|
names = sorted(t.name for t in tools)
|
|
@@ -123,7 +123,7 @@ async def test_shutdown_handler_only_on_sigterm_flag() -> None:
|
|
|
123
123
|
try:
|
|
124
124
|
# sanity connect so lifespan actually ran
|
|
125
125
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
126
|
-
c = MCPClient(mcp_config=cfg,
|
|
126
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
127
127
|
await c.initialize()
|
|
128
128
|
await c.shutdown()
|
|
129
129
|
finally:
|
|
@@ -140,7 +140,7 @@ async def test_shutdown_handler_only_on_sigterm_flag() -> None:
|
|
|
140
140
|
server_task2 = await _start_http_server(mcp, port=port2)
|
|
141
141
|
try:
|
|
142
142
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port2}/mcp"}}
|
|
143
|
-
c = MCPClient(mcp_config=cfg,
|
|
143
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
144
144
|
await c.initialize()
|
|
145
145
|
await c.shutdown()
|
|
146
146
|
|
|
@@ -170,7 +170,7 @@ async def test_initializer_exception_propagates_to_client() -> None:
|
|
|
170
170
|
server_task = await _start_http_server(mcp, port)
|
|
171
171
|
|
|
172
172
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
173
|
-
client = MCPClient(mcp_config=cfg,
|
|
173
|
+
client = MCPClient(mcp_config=cfg, verbose=False)
|
|
174
174
|
|
|
175
175
|
try:
|
|
176
176
|
with pytest.raises(Exception):
|
|
@@ -211,7 +211,7 @@ async def test_init_after_tools_preserves_handlers_and_runs_once() -> None:
|
|
|
211
211
|
|
|
212
212
|
async def connect_and_check() -> None:
|
|
213
213
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
214
|
-
c = MCPClient(mcp_config=cfg,
|
|
214
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
215
215
|
await c.initialize()
|
|
216
216
|
tools = await c.list_tools()
|
|
217
217
|
names = sorted(t.name for t in tools)
|
|
@@ -244,7 +244,7 @@ async def test_tool_default_argument_used_when_omitted() -> None:
|
|
|
244
244
|
server_task = await _start_http_server(mcp, port)
|
|
245
245
|
try:
|
|
246
246
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
247
|
-
c = MCPClient(mcp_config=cfg,
|
|
247
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
248
248
|
await c.initialize()
|
|
249
249
|
# Call with no args → default should kick in
|
|
250
250
|
res = await c.call_tool(name="echo", arguments={})
|
|
@@ -273,7 +273,7 @@ async def test_shutdown_handler_runs_once_when_both_paths_fire() -> None:
|
|
|
273
273
|
try:
|
|
274
274
|
# Ensure lifespan started
|
|
275
275
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
276
|
-
c = MCPClient(mcp_config=cfg,
|
|
276
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
277
277
|
await c.initialize()
|
|
278
278
|
await c.shutdown()
|
|
279
279
|
|
|
@@ -315,7 +315,7 @@ async def test_initialize_ctx_exposes_client_info() -> None:
|
|
|
315
315
|
server_task = await _start_http_server(mcp, port)
|
|
316
316
|
try:
|
|
317
317
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
318
|
-
c = MCPClient(mcp_config=cfg,
|
|
318
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
319
319
|
await c.initialize()
|
|
320
320
|
await c.shutdown()
|
|
321
321
|
finally:
|
|
@@ -344,7 +344,7 @@ async def test_initialize_redirects_stdout_to_stderr(capsys) -> None:
|
|
|
344
344
|
|
|
345
345
|
try:
|
|
346
346
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
347
|
-
c = MCPClient(mcp_config=cfg,
|
|
347
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
348
348
|
await c.initialize()
|
|
349
349
|
await c.shutdown()
|
|
350
350
|
finally:
|
|
@@ -373,11 +373,11 @@ async def test_initialize_callable_form_runs_once() -> None:
|
|
|
373
373
|
server_task = await _start_http_server(mcp, port)
|
|
374
374
|
try:
|
|
375
375
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
376
|
-
c1 = MCPClient(mcp_config=cfg,
|
|
376
|
+
c1 = MCPClient(mcp_config=cfg, verbose=False)
|
|
377
377
|
await c1.initialize()
|
|
378
378
|
await c1.shutdown()
|
|
379
379
|
|
|
380
|
-
c2 = MCPClient(mcp_config=cfg,
|
|
380
|
+
c2 = MCPClient(mcp_config=cfg, verbose=False)
|
|
381
381
|
await c2.initialize()
|
|
382
382
|
await c2.shutdown()
|
|
383
383
|
finally:
|
|
@@ -142,7 +142,7 @@ async def test_last_initialize_handler_wins_and_ctx_shape_exists() -> None:
|
|
|
142
142
|
|
|
143
143
|
try:
|
|
144
144
|
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
145
|
-
c = MCPClient(mcp_config=cfg,
|
|
145
|
+
c = MCPClient(mcp_config=cfg, verbose=False)
|
|
146
146
|
await c.initialize()
|
|
147
147
|
|
|
148
148
|
# Call a tool to ensure init didn't break anything
|
|
@@ -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/settings.py
CHANGED
|
@@ -53,23 +53,35 @@ class Settings(BaseSettings):
|
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
hud_telemetry_url: str = Field(
|
|
56
|
-
default="https://telemetry.hud.
|
|
56
|
+
default="https://telemetry.hud.ai/v3/api",
|
|
57
57
|
description="Base URL for the HUD API",
|
|
58
58
|
validation_alias="HUD_TELEMETRY_URL",
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
hud_mcp_url: str = Field(
|
|
62
|
-
default="https://mcp.hud.
|
|
62
|
+
default="https://mcp.hud.ai/v3/mcp",
|
|
63
63
|
description="Base URL for the MCP Server",
|
|
64
64
|
validation_alias="HUD_MCP_URL",
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
hud_rl_url: str = Field(
|
|
68
|
-
default="https://rl.hud.
|
|
68
|
+
default="https://rl.hud.ai/v1",
|
|
69
69
|
description="Base URL for the HUD RL API server",
|
|
70
70
|
validation_alias="HUD_RL_URL",
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
+
hud_api_url: str = Field(
|
|
74
|
+
default="https://api.hud.ai",
|
|
75
|
+
description="Base URL for the HUD API server",
|
|
76
|
+
validation_alias="HUD_API_URL",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
hud_gateway_url: str = Field(
|
|
80
|
+
default="https://inference.hud.ai",
|
|
81
|
+
description="Base URL for the HUD inference gateway",
|
|
82
|
+
validation_alias="HUD_GATEWAY_URL",
|
|
83
|
+
)
|
|
84
|
+
|
|
73
85
|
api_key: str | None = Field(
|
|
74
86
|
default=None,
|
|
75
87
|
description="API key for authentication with the HUD API",
|
|
@@ -82,12 +94,36 @@ class Settings(BaseSettings):
|
|
|
82
94
|
validation_alias="ANTHROPIC_API_KEY",
|
|
83
95
|
)
|
|
84
96
|
|
|
97
|
+
aws_access_key_id: str | None = Field(
|
|
98
|
+
default=None,
|
|
99
|
+
description="AWS access key ID for Bedrock",
|
|
100
|
+
validation_alias="AWS_ACCESS_KEY_ID",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
aws_secret_access_key: str | None = Field(
|
|
104
|
+
default=None,
|
|
105
|
+
description="AWS secret access key for Bedrock",
|
|
106
|
+
validation_alias="AWS_SECRET_ACCESS_KEY",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
aws_region: str | None = Field(
|
|
110
|
+
default=None,
|
|
111
|
+
description="AWS region for Bedrock (e.g., us-east-1)",
|
|
112
|
+
validation_alias="AWS_REGION",
|
|
113
|
+
)
|
|
114
|
+
|
|
85
115
|
openai_api_key: str | None = Field(
|
|
86
116
|
default=None,
|
|
87
117
|
description="API key for OpenAI models",
|
|
88
118
|
validation_alias="OPENAI_API_KEY",
|
|
89
119
|
)
|
|
90
120
|
|
|
121
|
+
gemini_api_key: str | None = Field(
|
|
122
|
+
default=None,
|
|
123
|
+
description="API key for Google Gemini models",
|
|
124
|
+
validation_alias="GEMINI_API_KEY",
|
|
125
|
+
)
|
|
126
|
+
|
|
91
127
|
openrouter_api_key: str | None = Field(
|
|
92
128
|
default=None,
|
|
93
129
|
description="API key for OpenRouter models",
|
|
@@ -124,6 +160,12 @@ class Settings(BaseSettings):
|
|
|
124
160
|
validation_alias="HUD_LOG_STREAM",
|
|
125
161
|
)
|
|
126
162
|
|
|
163
|
+
client_timeout: int = Field(
|
|
164
|
+
default=900,
|
|
165
|
+
description="Timeout in seconds for MCP client operations (default: 900 = 15 minutes)",
|
|
166
|
+
validation_alias="HUD_CLIENT_TIMEOUT",
|
|
167
|
+
)
|
|
168
|
+
|
|
127
169
|
|
|
128
170
|
# Create a singleton instance
|
|
129
171
|
settings = Settings()
|
hud/shared/exceptions.py
CHANGED
|
@@ -69,11 +69,6 @@ class HudException(Exception):
|
|
|
69
69
|
elif isinstance(exc_value, Exception):
|
|
70
70
|
# Try to convert to a specific HudException
|
|
71
71
|
result = cls._analyze_exception(exc_value, message or str(exc_value))
|
|
72
|
-
# If we couldn't categorize it (still base HudException),
|
|
73
|
-
# just re-raise the original exception
|
|
74
|
-
if type(result) is HudException:
|
|
75
|
-
# Re-raise the original exception unchanged
|
|
76
|
-
raise exc_value from None
|
|
77
72
|
return result
|
|
78
73
|
|
|
79
74
|
# Normal creation
|
|
@@ -136,7 +131,7 @@ class HudException(Exception):
|
|
|
136
131
|
),
|
|
137
132
|
(
|
|
138
133
|
lambda: ("api key" in error_msg or "authorization" in error_msg)
|
|
139
|
-
and ("hud" in error_msg or "mcp.hud.
|
|
134
|
+
and ("hud" in error_msg or "mcp.hud.ai" in error_msg),
|
|
140
135
|
HudAuthenticationError,
|
|
141
136
|
),
|
|
142
137
|
(
|
|
@@ -190,11 +185,42 @@ class HudRequestError(HudException):
|
|
|
190
185
|
self.response_text = response_text
|
|
191
186
|
self.response_headers = response_headers
|
|
192
187
|
# Compute default hints from status code if none provided
|
|
193
|
-
if hints is None and status_code in (401, 403, 429):
|
|
188
|
+
if hints is None and status_code in (401, 402, 403, 429):
|
|
194
189
|
try:
|
|
195
|
-
from hud.shared.hints import
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
198
224
|
hints = [HUD_API_KEY_MISSING]
|
|
199
225
|
elif status_code == 429:
|
|
200
226
|
hints = [RATE_LIMIT_HIT]
|
hud/shared/hints.py
CHANGED
|
@@ -38,7 +38,7 @@ HUD_API_KEY_MISSING = Hint(
|
|
|
38
38
|
message="Missing or invalid HUD_API_KEY.",
|
|
39
39
|
tips=[
|
|
40
40
|
"Set HUD_API_KEY in your environment or run: hud set HUD_API_KEY=your-key-here",
|
|
41
|
-
"Get a key at https://hud.
|
|
41
|
+
"Get a key at https://hud.ai",
|
|
42
42
|
"Check for whitespace or truncation",
|
|
43
43
|
],
|
|
44
44
|
docs_url=None,
|
|
@@ -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.ai/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.ai/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:
|
|
@@ -7,7 +7,7 @@ classification and helpful hints for users.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
-
from unittest.mock import Mock
|
|
10
|
+
from unittest.mock import Mock
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
13
|
import pytest
|
|
@@ -26,6 +26,7 @@ from hud.shared.hints import (
|
|
|
26
26
|
CLIENT_NOT_INITIALIZED,
|
|
27
27
|
HUD_API_KEY_MISSING,
|
|
28
28
|
INVALID_CONFIG,
|
|
29
|
+
PRO_PLAN_REQUIRED,
|
|
29
30
|
RATE_LIMIT_HIT,
|
|
30
31
|
TOOL_NOT_FOUND,
|
|
31
32
|
)
|
|
@@ -98,7 +99,7 @@ class TestHudExceptionAutoConversion:
|
|
|
98
99
|
def test_hud_api_key_error(self):
|
|
99
100
|
"""Test that HUD API key errors become HudAuthenticationError."""
|
|
100
101
|
try:
|
|
101
|
-
raise ValueError("API key missing for mcp.hud.
|
|
102
|
+
raise ValueError("API key missing for mcp.hud.ai")
|
|
102
103
|
except Exception as e:
|
|
103
104
|
with pytest.raises(HudAuthenticationError) as exc_info:
|
|
104
105
|
raise HudException from e
|
|
@@ -156,25 +157,23 @@ class TestHudExceptionAutoConversion:
|
|
|
156
157
|
assert str(exc_info.value) == "Async operation timed out"
|
|
157
158
|
|
|
158
159
|
def test_generic_error_remains_hudexception(self):
|
|
159
|
-
"""
|
|
160
|
+
"""Uncategorized errors become base HudException with original message."""
|
|
160
161
|
try:
|
|
161
162
|
raise ValueError("Some random error")
|
|
162
163
|
except Exception as e:
|
|
163
164
|
with pytest.raises(HudException) as exc_info:
|
|
164
165
|
raise HudException from e
|
|
165
|
-
|
|
166
|
-
# Should be base HudException, not a subclass
|
|
166
|
+
# Should be base HudException, not subclass
|
|
167
167
|
assert type(exc_info.value) is HudException
|
|
168
|
-
assert exc_info.value
|
|
168
|
+
assert str(exc_info.value) == "Some random error"
|
|
169
169
|
|
|
170
170
|
def test_custom_message_override(self):
|
|
171
|
-
"""
|
|
171
|
+
"""Custom message should be used for categorized errors."""
|
|
172
172
|
try:
|
|
173
|
-
raise ValueError("
|
|
173
|
+
raise ValueError("Client not initialized - call initialize() first")
|
|
174
174
|
except Exception as e:
|
|
175
|
-
with pytest.raises(
|
|
175
|
+
with pytest.raises(HudClientError) as exc_info:
|
|
176
176
|
raise HudException("Custom error message") from e
|
|
177
|
-
|
|
178
177
|
assert str(exc_info.value) == "Custom error message"
|
|
179
178
|
|
|
180
179
|
def test_already_hud_exception_passthrough(self):
|
|
@@ -204,6 +203,22 @@ class TestHudRequestError:
|
|
|
204
203
|
error = HudRequestError("Forbidden", status_code=403)
|
|
205
204
|
assert HUD_API_KEY_MISSING in error.hints
|
|
206
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
|
+
|
|
207
222
|
def test_429_adds_rate_limit_hint(self):
|
|
208
223
|
"""Test that 429 status adds rate limit hint."""
|
|
209
224
|
error = HudRequestError("Too Many Requests", status_code=429)
|
|
@@ -243,23 +258,19 @@ class TestMCPErrorHandling:
|
|
|
243
258
|
@pytest.mark.asyncio
|
|
244
259
|
async def test_mcp_error_handling(self):
|
|
245
260
|
"""Test that McpError is handled appropriately."""
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
MockMcpError.side_effect = Exception
|
|
261
|
+
# Create a dynamic class named "McpError" to trigger name-based detection
|
|
262
|
+
McpError = type("McpError", (Exception,), {})
|
|
249
263
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# This would typically be caught in the client code
|
|
258
|
-
# and re-raised as HudException
|
|
259
|
-
with pytest.raises(HudException) as exc_info:
|
|
260
|
-
raise HudException from e
|
|
264
|
+
try:
|
|
265
|
+
raise McpError("MCP protocol error: Unknown method")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
# This would typically be caught in the client code
|
|
268
|
+
# and re-raised as HudException
|
|
269
|
+
with pytest.raises(HudException) as exc_info:
|
|
270
|
+
raise HudException from e
|
|
261
271
|
|
|
262
|
-
|
|
272
|
+
assert "MCP protocol error" in str(exc_info.value)
|
|
273
|
+
assert "MCP protocol error" in str(exc_info.value)
|
|
263
274
|
|
|
264
275
|
def test_mcp_tool_error_result(self):
|
|
265
276
|
"""Test handling of MCP tool execution errors (isError: true)."""
|
|
@@ -352,7 +363,8 @@ class TestExceptionRendering:
|
|
|
352
363
|
assert len(error.hints) == 1
|
|
353
364
|
assert error.hints[0] == HUD_API_KEY_MISSING
|
|
354
365
|
assert error.hints[0].title == "HUD API key required"
|
|
355
|
-
|
|
366
|
+
# Hint copy evolved; keep the assertion robust to minor copy changes
|
|
367
|
+
assert "Set HUD_API_KEY" in error.hints[0].tips[0]
|
|
356
368
|
|
|
357
369
|
def test_exception_type_preservation(self):
|
|
358
370
|
"""Test that exception types are preserved through conversion."""
|
|
@@ -396,16 +408,13 @@ class TestEdgeCases:
|
|
|
396
408
|
assert type(error) is HudException
|
|
397
409
|
|
|
398
410
|
def test_empty_error_message(self):
|
|
399
|
-
"""
|
|
411
|
+
"""Empty message still results in a HudException instance."""
|
|
400
412
|
try:
|
|
401
413
|
raise ValueError("")
|
|
402
414
|
except Exception as e:
|
|
403
|
-
with pytest.raises(HudException)
|
|
415
|
+
with pytest.raises(HudException):
|
|
404
416
|
raise HudException from e
|
|
405
417
|
|
|
406
|
-
# Should still have some message
|
|
407
|
-
assert str(exc_info.value) != ""
|
|
408
|
-
|
|
409
418
|
def test_circular_exception_chain(self):
|
|
410
419
|
"""Test that we don't create circular exception chains."""
|
|
411
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])
|