hud-python 0.4.36__py3-none-any.whl → 0.4.37__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/__init__.py +2 -0
- hud/agents/lite_llm.py +72 -0
- hud/agents/openai_chat_generic.py +21 -7
- hud/cli/__init__.py +19 -4
- hud/cli/build.py +17 -2
- hud/cli/dev.py +1 -1
- hud/cli/eval.py +93 -13
- hud/cli/flows/tasks.py +197 -65
- hud/cli/push.py +9 -0
- hud/cli/rl/__init__.py +14 -4
- hud/cli/rl/celebrate.py +187 -0
- hud/cli/rl/config.py +15 -8
- hud/cli/rl/local_runner.py +44 -20
- hud/cli/rl/remote_runner.py +163 -86
- hud/cli/rl/viewer.py +141 -0
- hud/cli/rl/wait_utils.py +89 -0
- hud/cli/utils/env_check.py +196 -0
- hud/cli/utils/source_hash.py +108 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +1 -1
- hud/otel/config.py +1 -1
- hud/otel/context.py +2 -2
- hud/rl/vllm_adapter.py +1 -1
- hud/server/server.py +84 -13
- hud/server/tests/test_add_tool.py +60 -0
- hud/server/tests/test_context.py +128 -0
- hud/server/tests/test_mcp_server_handlers.py +44 -0
- hud/server/tests/test_mcp_server_integration.py +405 -0
- hud/server/tests/test_mcp_server_more.py +247 -0
- hud/server/tests/test_run_wrapper.py +53 -0
- hud/server/tests/test_server_extra.py +166 -0
- hud/server/tests/test_sigterm_runner.py +78 -0
- hud/shared/hints.py +1 -1
- hud/telemetry/job.py +2 -2
- hud/types.py +9 -2
- hud/utils/tasks.py +32 -24
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/METADATA +14 -12
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/RECORD +43 -29
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
from contextlib import asynccontextmanager, suppress
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hud.clients import MCPClient
|
|
11
|
+
from hud.server import MCPServer
|
|
12
|
+
from hud.server import server as server_mod # to toggle _sigterm_received
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _free_port() -> int:
|
|
16
|
+
"""Get a free port for testing."""
|
|
17
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
18
|
+
s.bind(("127.0.0.1", 0))
|
|
19
|
+
return s.getsockname()[1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def _fake_stdio_server():
|
|
24
|
+
"""
|
|
25
|
+
Stand-in for mcp.server.stdio.stdio_server that avoids reading real stdin.
|
|
26
|
+
|
|
27
|
+
It yields a pair of in-memory streams (receive, send) so the low-level server
|
|
28
|
+
can start and idle without touching sys.stdin/sys.stdout.
|
|
29
|
+
"""
|
|
30
|
+
# Server reads from recv_in and writes to send_out
|
|
31
|
+
send_in, recv_in = anyio.create_memory_object_stream(100) # stdin → server
|
|
32
|
+
send_out, recv_out = anyio.create_memory_object_stream(100) # server → stdout
|
|
33
|
+
try:
|
|
34
|
+
yield recv_in, send_out
|
|
35
|
+
finally:
|
|
36
|
+
# Best effort close; methods exist across anyio versions
|
|
37
|
+
for s in (send_in, recv_in, send_out, recv_out):
|
|
38
|
+
close = getattr(s, "close", None) or getattr(s, "aclose", None)
|
|
39
|
+
try:
|
|
40
|
+
if close is not None:
|
|
41
|
+
res = close()
|
|
42
|
+
if asyncio.iscoroutine(res):
|
|
43
|
+
await res
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(autouse=True)
|
|
49
|
+
def _patch_stdio(monkeypatch: pytest.MonkeyPatch):
|
|
50
|
+
"""Patch stdio server for all tests to avoid stdin reading issues."""
|
|
51
|
+
monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
|
|
52
|
+
# Patch both the source and the bound symbol FastMCP uses
|
|
53
|
+
monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
|
|
54
|
+
monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_stdio_shutdown_handler_on_sigterm_flag() -> None:
|
|
59
|
+
"""@mcp.shutdown runs on stdio transport when the SIGTERM flag is set."""
|
|
60
|
+
mcp = MCPServer(name="StdIOShutdown")
|
|
61
|
+
calls = {"n": 0}
|
|
62
|
+
|
|
63
|
+
@mcp.shutdown
|
|
64
|
+
async def _on_shutdown() -> None:
|
|
65
|
+
calls["n"] += 1
|
|
66
|
+
|
|
67
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
68
|
+
try:
|
|
69
|
+
await asyncio.sleep(0.05)
|
|
70
|
+
# Simulate SIGTERM path
|
|
71
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
72
|
+
finally:
|
|
73
|
+
with suppress(asyncio.CancelledError):
|
|
74
|
+
task.cancel()
|
|
75
|
+
await task
|
|
76
|
+
|
|
77
|
+
assert calls["n"] == 1
|
|
78
|
+
assert not getattr(server_mod, "_sigterm_received")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_stdio_shutdown_handler_not_called_without_sigterm() -> None:
|
|
83
|
+
"""@mcp.shutdown must NOT run on stdio cancel when no SIGTERM flag."""
|
|
84
|
+
mcp = MCPServer(name="StdIONoSigterm")
|
|
85
|
+
called = {"n": 0}
|
|
86
|
+
|
|
87
|
+
@mcp.shutdown
|
|
88
|
+
async def _on_shutdown() -> None:
|
|
89
|
+
called["n"] += 1
|
|
90
|
+
|
|
91
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
92
|
+
try:
|
|
93
|
+
await asyncio.sleep(0.05)
|
|
94
|
+
# no SIGTERM flag
|
|
95
|
+
finally:
|
|
96
|
+
with suppress(asyncio.CancelledError):
|
|
97
|
+
task.cancel()
|
|
98
|
+
await task
|
|
99
|
+
|
|
100
|
+
assert called["n"] == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_last_initialize_handler_wins_and_ctx_shape_exists() -> None:
|
|
105
|
+
"""If multiple @initialize decorators are applied, only the last one should execute.
|
|
106
|
+
Also sanity-check that ctx has the expected core attributes in a version-tolerant way.
|
|
107
|
+
"""
|
|
108
|
+
port = _free_port()
|
|
109
|
+
|
|
110
|
+
mcp = MCPServer(name="InitOverride")
|
|
111
|
+
seen = {"a": False, "b": False, "has_session": False, "has_request": False}
|
|
112
|
+
|
|
113
|
+
@mcp.initialize
|
|
114
|
+
async def _init_a(ctx) -> None: # type: ignore[override]
|
|
115
|
+
# This one should get overridden and never run
|
|
116
|
+
seen["a"] = True
|
|
117
|
+
|
|
118
|
+
@mcp.initialize
|
|
119
|
+
async def _init_b(ctx) -> None: # type: ignore[override]
|
|
120
|
+
# This is the one that should actually run
|
|
121
|
+
seen["b"] = True
|
|
122
|
+
seen["has_session"] = hasattr(ctx, "session") and ctx.session is not None
|
|
123
|
+
seen["has_request"] = hasattr(ctx, "request") and ctx.request is not None
|
|
124
|
+
|
|
125
|
+
# A simple echo tool so we can verify the server works post-init
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
async def echo(text: str = "ok") -> str: # type: ignore[override]
|
|
128
|
+
return f"echo:{text}"
|
|
129
|
+
|
|
130
|
+
# Start HTTP transport (quickest way to use a real client)
|
|
131
|
+
task = asyncio.create_task(
|
|
132
|
+
mcp.run_async(
|
|
133
|
+
transport="http",
|
|
134
|
+
host="127.0.0.1",
|
|
135
|
+
port=port,
|
|
136
|
+
path="/mcp",
|
|
137
|
+
log_level="ERROR",
|
|
138
|
+
show_banner=False,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
await asyncio.sleep(0.05)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
145
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
146
|
+
await c.initialize()
|
|
147
|
+
|
|
148
|
+
# Call a tool to ensure init didn't break anything
|
|
149
|
+
res = await c.call_tool(name="echo", arguments={"text": "ping"})
|
|
150
|
+
text = getattr(res, "content", None)
|
|
151
|
+
if isinstance(text, list) and text and hasattr(text[0], "text"):
|
|
152
|
+
text = text[0].text
|
|
153
|
+
assert text == "echo:ping"
|
|
154
|
+
|
|
155
|
+
await c.shutdown()
|
|
156
|
+
finally:
|
|
157
|
+
with suppress(asyncio.CancelledError):
|
|
158
|
+
task.cancel()
|
|
159
|
+
await task
|
|
160
|
+
|
|
161
|
+
# Only the last initializer should have run
|
|
162
|
+
assert seen["a"] is False
|
|
163
|
+
assert seen["b"] is True
|
|
164
|
+
# And the ctx had the key attributes (shape may vary by lib version; just presence)
|
|
165
|
+
assert seen["has_session"] is True
|
|
166
|
+
assert seen["has_request"] is True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_stdio_shutdown_handler_runs_once_when_both_paths_fire() -> None:
|
|
171
|
+
"""Even on stdio, when SIGTERM is set, ensure shutdown runs exactly once."""
|
|
172
|
+
mcp = MCPServer(name="StdIOOnce")
|
|
173
|
+
calls = {"n": 0}
|
|
174
|
+
|
|
175
|
+
@mcp.shutdown
|
|
176
|
+
async def _on_shutdown() -> None:
|
|
177
|
+
calls["n"] += 1
|
|
178
|
+
|
|
179
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
180
|
+
try:
|
|
181
|
+
await asyncio.sleep(0.05)
|
|
182
|
+
# Make both the lifespan.finally and run_async.finally want to execute
|
|
183
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
184
|
+
finally:
|
|
185
|
+
with suppress(asyncio.CancelledError):
|
|
186
|
+
task.cancel()
|
|
187
|
+
await task
|
|
188
|
+
|
|
189
|
+
assert calls["n"] == 1
|
|
190
|
+
# Reset global flag always
|
|
191
|
+
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_run_async_defaults_to_stdio_and_uses_patched_stdio(monkeypatch: pytest.MonkeyPatch):
|
|
196
|
+
"""transport=None should default to stdio and use our patched stdio server."""
|
|
197
|
+
entered = {"v": False}
|
|
198
|
+
|
|
199
|
+
@asynccontextmanager
|
|
200
|
+
async def tracking_stdio():
|
|
201
|
+
entered["v"] = True
|
|
202
|
+
async with _fake_stdio_server() as streams:
|
|
203
|
+
yield streams
|
|
204
|
+
|
|
205
|
+
# Override the autouse fixture for this test to track entry
|
|
206
|
+
monkeypatch.setattr("fastmcp.server.server.stdio_server", tracking_stdio)
|
|
207
|
+
|
|
208
|
+
mcp = MCPServer(name="DefaultStdio")
|
|
209
|
+
task = asyncio.create_task(mcp.run_async(transport=None, show_banner=False))
|
|
210
|
+
try:
|
|
211
|
+
await asyncio.sleep(0.05)
|
|
212
|
+
assert entered["v"] is True, "Expected stdio transport to be used by default"
|
|
213
|
+
finally:
|
|
214
|
+
with suppress(asyncio.CancelledError):
|
|
215
|
+
task.cancel()
|
|
216
|
+
await task
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.asyncio
|
|
220
|
+
async def test_custom_lifespan_relies_on_run_async_fallback_for_sigterm() -> None:
|
|
221
|
+
"""When a custom lifespan is supplied, run_async's finally path must still call
|
|
222
|
+
@shutdown on SIGTERM."""
|
|
223
|
+
|
|
224
|
+
@asynccontextmanager
|
|
225
|
+
async def custom_lifespan(_):
|
|
226
|
+
# No shutdown call here on purpose
|
|
227
|
+
yield {}
|
|
228
|
+
|
|
229
|
+
mcp = MCPServer(name="CustomLS", lifespan=custom_lifespan)
|
|
230
|
+
calls = {"n": 0}
|
|
231
|
+
|
|
232
|
+
@mcp.shutdown
|
|
233
|
+
async def _s() -> None:
|
|
234
|
+
calls["n"] += 1
|
|
235
|
+
|
|
236
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
237
|
+
try:
|
|
238
|
+
await asyncio.sleep(0.05)
|
|
239
|
+
# Ensure finalizer believes SIGTERM happened
|
|
240
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
241
|
+
finally:
|
|
242
|
+
with suppress(asyncio.CancelledError):
|
|
243
|
+
task.cancel()
|
|
244
|
+
await task
|
|
245
|
+
|
|
246
|
+
assert calls["n"] == 1
|
|
247
|
+
assert not getattr(server_mod, "_sigterm_received")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from hud.server import MCPServer
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_run_uses_sigterm_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
13
|
+
"""MCPServer.run should delegate through _run_with_sigterm (don't actually start a server)."""
|
|
14
|
+
called = {"hit": False, "args": None, "kwargs": None}
|
|
15
|
+
|
|
16
|
+
def fake_wrapper(coro_fn, *args, **kwargs):
|
|
17
|
+
called["hit"] = True
|
|
18
|
+
called["args"] = args
|
|
19
|
+
called["kwargs"] = kwargs
|
|
20
|
+
# Do not execute the bootstrap coroutine; this is unit wiring only.
|
|
21
|
+
|
|
22
|
+
monkeypatch.setattr("hud.server.server._run_with_sigterm", fake_wrapper)
|
|
23
|
+
|
|
24
|
+
mcp = MCPServer(name="RunWrapper")
|
|
25
|
+
# Should immediately return after calling our fake wrapper
|
|
26
|
+
mcp.run(transport="http", host="127.0.0.1", port=9999, path="/mcp", show_banner=False)
|
|
27
|
+
|
|
28
|
+
assert called["hit"] is True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_run_defaults_to_stdio(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
32
|
+
"""transport=None in .run should resolve to 'stdio' and forward to run_async."""
|
|
33
|
+
seen = {}
|
|
34
|
+
|
|
35
|
+
async def fake_run_async(self, *, transport, show_banner, **kwargs):
|
|
36
|
+
seen["transport"] = transport
|
|
37
|
+
seen["show_banner"] = show_banner
|
|
38
|
+
seen["kwargs"] = kwargs
|
|
39
|
+
|
|
40
|
+
# Replace bound method on the instance class
|
|
41
|
+
monkeypatch.setattr(MCPServer, "run_async", fake_run_async, raising=False)
|
|
42
|
+
|
|
43
|
+
# Execute the bootstrap coroutine immediately (no real server)
|
|
44
|
+
def fake_wrapper(coro_fn, *args, **kwargs):
|
|
45
|
+
asyncio.run(coro_fn())
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr("hud.server.server._run_with_sigterm", fake_wrapper)
|
|
48
|
+
|
|
49
|
+
mcp = MCPServer(name="RunDefaultTransport")
|
|
50
|
+
mcp.run(transport=None, show_banner=False)
|
|
51
|
+
|
|
52
|
+
assert seen["transport"] == "stdio"
|
|
53
|
+
assert seen["show_banner"] is False
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# filename: hud/server/tests/test_server_extra.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from contextlib import asynccontextmanager, suppress
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hud.server import MCPServer
|
|
11
|
+
from hud.server import server as server_mod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def _fake_stdio_server():
|
|
16
|
+
"""
|
|
17
|
+
Stand-in for stdio_server that avoids reading real stdin.
|
|
18
|
+
|
|
19
|
+
It yields a pair of in-memory streams (receive, send) so the low-level server
|
|
20
|
+
can start and idle without touching sys.stdin/sys.stdout.
|
|
21
|
+
"""
|
|
22
|
+
send_in, recv_in = anyio.create_memory_object_stream(100)
|
|
23
|
+
send_out, recv_out = anyio.create_memory_object_stream(100)
|
|
24
|
+
try:
|
|
25
|
+
yield recv_in, send_out
|
|
26
|
+
finally:
|
|
27
|
+
# best-effort close across anyio versions
|
|
28
|
+
for s in (send_in, recv_in, send_out, recv_out):
|
|
29
|
+
close = getattr(s, "close", None) or getattr(s, "aclose", None)
|
|
30
|
+
try:
|
|
31
|
+
if close is not None:
|
|
32
|
+
res = close()
|
|
33
|
+
if asyncio.iscoroutine(res):
|
|
34
|
+
await res
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def patch_stdio(monkeypatch: pytest.MonkeyPatch):
|
|
41
|
+
"""Patch stdio server to avoid stdin issues during tests."""
|
|
42
|
+
monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
|
|
43
|
+
monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
|
|
44
|
+
monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_sigterm_flag_remains_true_without_shutdown_handler(patch_stdio):
|
|
49
|
+
"""
|
|
50
|
+
When no @mcp.shutdown is registered, neither the lifespan.finally nor run_async.finally
|
|
51
|
+
should reset the global SIGTERM flag. This exercises the 'no handler' branches.
|
|
52
|
+
"""
|
|
53
|
+
mcp = MCPServer(name="NoShutdownHandler")
|
|
54
|
+
|
|
55
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
56
|
+
try:
|
|
57
|
+
await asyncio.sleep(0.05)
|
|
58
|
+
# Simulate SIGTERM path
|
|
59
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
60
|
+
finally:
|
|
61
|
+
with suppress(asyncio.CancelledError):
|
|
62
|
+
task.cancel()
|
|
63
|
+
await task
|
|
64
|
+
|
|
65
|
+
# Flag must remain set since no shutdown handler was installed
|
|
66
|
+
assert getattr(server_mod, "_sigterm_received") is True
|
|
67
|
+
|
|
68
|
+
# Always reset for other tests
|
|
69
|
+
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_last_shutdown_handler_wins(patch_stdio):
|
|
74
|
+
"""
|
|
75
|
+
If multiple @mcp.shutdown decorators are applied, the last one should be the one that runs.
|
|
76
|
+
"""
|
|
77
|
+
mcp = MCPServer(name="ShutdownOverride")
|
|
78
|
+
calls: list[str] = []
|
|
79
|
+
|
|
80
|
+
@mcp.shutdown
|
|
81
|
+
async def _first() -> None:
|
|
82
|
+
calls.append("first")
|
|
83
|
+
|
|
84
|
+
@mcp.shutdown
|
|
85
|
+
async def _second() -> None:
|
|
86
|
+
calls.append("second")
|
|
87
|
+
|
|
88
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
89
|
+
try:
|
|
90
|
+
await asyncio.sleep(0.05)
|
|
91
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
92
|
+
finally:
|
|
93
|
+
with suppress(asyncio.CancelledError):
|
|
94
|
+
task.cancel()
|
|
95
|
+
await task
|
|
96
|
+
|
|
97
|
+
assert calls == ["second"], "Only the last registered shutdown handler should run"
|
|
98
|
+
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test__run_with_sigterm_registers_handlers_when_enabled(monkeypatch: pytest.MonkeyPatch):
|
|
102
|
+
"""
|
|
103
|
+
Verify that _run_with_sigterm attempts to register SIGTERM/SIGINT handlers
|
|
104
|
+
when the env var does NOT disable the handler. We stub AnyIO's TaskGroup so
|
|
105
|
+
the watcher doesn't block and the test returns immediately.
|
|
106
|
+
"""
|
|
107
|
+
# Ensure handler is enabled
|
|
108
|
+
monkeypatch.delenv("FASTMCP_DISABLE_SIGTERM_HANDLER", raising=False)
|
|
109
|
+
|
|
110
|
+
# Record what the server tries to register
|
|
111
|
+
added_signals: list[int] = []
|
|
112
|
+
|
|
113
|
+
import asyncio as _asyncio
|
|
114
|
+
|
|
115
|
+
orig_get_running_loop = _asyncio.get_running_loop
|
|
116
|
+
|
|
117
|
+
def proxy_get_running_loop():
|
|
118
|
+
real = orig_get_running_loop()
|
|
119
|
+
|
|
120
|
+
class _LoopProxy:
|
|
121
|
+
__slots__ = ("_inner",)
|
|
122
|
+
|
|
123
|
+
def __init__(self, inner):
|
|
124
|
+
self._inner = inner
|
|
125
|
+
|
|
126
|
+
def add_signal_handler(self, signum, callback, *args):
|
|
127
|
+
added_signals.append(signum) # don't actually install
|
|
128
|
+
# no-op: skip calling inner.add_signal_handler to avoid OS constraints
|
|
129
|
+
|
|
130
|
+
def __getattr__(self, name):
|
|
131
|
+
# delegate everything else (create_task, call_soon, etc.)
|
|
132
|
+
return getattr(self._inner, name)
|
|
133
|
+
|
|
134
|
+
return _LoopProxy(real)
|
|
135
|
+
|
|
136
|
+
# Patch globally so both the test and hud.server.server see the proxy
|
|
137
|
+
monkeypatch.setattr(_asyncio, "get_running_loop", proxy_get_running_loop)
|
|
138
|
+
|
|
139
|
+
# Dummy TaskGroup that runs the work but skips _watch
|
|
140
|
+
class _DummyTG:
|
|
141
|
+
async def __aenter__(self):
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def start_soon(self, fn, *args, **kwargs):
|
|
148
|
+
if getattr(fn, "__name__", "") == "_watch":
|
|
149
|
+
return
|
|
150
|
+
_asyncio.get_running_loop().create_task(fn(*args, **kwargs))
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr("anyio.create_task_group", lambda: _DummyTG())
|
|
153
|
+
|
|
154
|
+
# Simple coroutine that should run to completion
|
|
155
|
+
hit = {"v": False}
|
|
156
|
+
|
|
157
|
+
async def work():
|
|
158
|
+
hit["v"] = True
|
|
159
|
+
|
|
160
|
+
server_mod._run_with_sigterm(work)
|
|
161
|
+
assert hit["v"] is True
|
|
162
|
+
|
|
163
|
+
import signal as _signal
|
|
164
|
+
|
|
165
|
+
assert _signal.SIGTERM in added_signals
|
|
166
|
+
assert _signal.SIGINT in added_signals
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import asynccontextmanager, suppress
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hud.server import MCPServer
|
|
10
|
+
from hud.server import server as server_mod
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test__run_with_sigterm_executes_coro_when_handler_disabled(monkeypatch: pytest.MonkeyPatch):
|
|
14
|
+
"""With FASTMCP_DISABLE_SIGTERM_HANDLER=1, _run_with_sigterm should just run the task."""
|
|
15
|
+
monkeypatch.setenv("FASTMCP_DISABLE_SIGTERM_HANDLER", "1")
|
|
16
|
+
|
|
17
|
+
hit = {"v": False}
|
|
18
|
+
|
|
19
|
+
async def work(arg, *, kw=None):
|
|
20
|
+
assert arg == 123 and kw == "ok"
|
|
21
|
+
hit["v"] = True
|
|
22
|
+
|
|
23
|
+
# Wrapper to exercise kwargs since TaskGroup.start_soon only accepts positional args
|
|
24
|
+
async def wrapper(arg):
|
|
25
|
+
await work(arg, kw="ok")
|
|
26
|
+
|
|
27
|
+
# Should return cleanly and mark hit
|
|
28
|
+
server_mod._run_with_sigterm(wrapper, 123)
|
|
29
|
+
assert hit["v"] is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def _fake_stdio_server():
|
|
34
|
+
"""Stand-in for stdio_server that avoids reading real stdin."""
|
|
35
|
+
send_in, recv_in = anyio.create_memory_object_stream(100)
|
|
36
|
+
send_out, recv_out = anyio.create_memory_object_stream(100)
|
|
37
|
+
try:
|
|
38
|
+
yield recv_in, send_out
|
|
39
|
+
finally:
|
|
40
|
+
for s in (send_in, recv_in, send_out, recv_out):
|
|
41
|
+
close = getattr(s, "close", None) or getattr(s, "aclose", None)
|
|
42
|
+
try:
|
|
43
|
+
if close is not None:
|
|
44
|
+
res = close()
|
|
45
|
+
if asyncio.iscoroutine(res):
|
|
46
|
+
await res
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def patch_stdio(monkeypatch: pytest.MonkeyPatch):
|
|
53
|
+
"""Patch stdio server to avoid stdin issues during tests."""
|
|
54
|
+
monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
|
|
55
|
+
monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
|
|
56
|
+
monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_shutdown_handler_exception_does_not_crash_and_resets_flag(patch_stdio):
|
|
61
|
+
"""If @shutdown raises, run_async must swallow it and still reset the SIGTERM flag."""
|
|
62
|
+
mcp = MCPServer(name="ShutdownRaises")
|
|
63
|
+
|
|
64
|
+
@mcp.shutdown
|
|
65
|
+
async def _boom() -> None:
|
|
66
|
+
raise RuntimeError("kaboom")
|
|
67
|
+
|
|
68
|
+
task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
|
|
69
|
+
try:
|
|
70
|
+
await asyncio.sleep(0.05)
|
|
71
|
+
server_mod._sigterm_received = True # trigger shutdown path
|
|
72
|
+
finally:
|
|
73
|
+
with suppress(asyncio.CancelledError):
|
|
74
|
+
task.cancel()
|
|
75
|
+
await task
|
|
76
|
+
|
|
77
|
+
# No exception propagated; flag must be reset
|
|
78
|
+
assert not getattr(server_mod, "_sigterm_received")
|
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://
|
|
41
|
+
"Get a key at https://hud.so",
|
|
42
42
|
"Check for whitespace or truncation",
|
|
43
43
|
],
|
|
44
44
|
docs_url=None,
|
hud/telemetry/job.py
CHANGED
|
@@ -143,7 +143,7 @@ def _print_job_url(job_id: str, job_name: str) -> None:
|
|
|
143
143
|
if not (settings.telemetry_enabled and settings.api_key):
|
|
144
144
|
return
|
|
145
145
|
|
|
146
|
-
url = f"https://
|
|
146
|
+
url = f"https://hud.so/jobs/{job_id}"
|
|
147
147
|
header = f"🚀 Job '{job_name}' started:"
|
|
148
148
|
|
|
149
149
|
# ANSI color codes
|
|
@@ -182,7 +182,7 @@ def _print_job_complete_url(job_id: str, job_name: str, error_occurred: bool = F
|
|
|
182
182
|
if not (settings.telemetry_enabled and settings.api_key):
|
|
183
183
|
return
|
|
184
184
|
|
|
185
|
-
url = f"https://
|
|
185
|
+
url = f"https://hud.so/jobs/{job_id}"
|
|
186
186
|
|
|
187
187
|
# ANSI color codes
|
|
188
188
|
GREEN = "\033[92m"
|
hud/types.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import uuid
|
|
@@ -107,7 +108,13 @@ class Task(BaseModel):
|
|
|
107
108
|
|
|
108
109
|
# Start with current environment variables
|
|
109
110
|
mapping = dict(os.environ)
|
|
110
|
-
|
|
111
|
+
# Include settings (from process env, project .env, and user .env)
|
|
112
|
+
settings_dict = settings.model_dump()
|
|
113
|
+
mapping.update(settings_dict)
|
|
114
|
+
# Add UPPERCASE aliases for settings keys
|
|
115
|
+
for _key, _val in settings_dict.items():
|
|
116
|
+
with contextlib.suppress(Exception):
|
|
117
|
+
mapping[_key.upper()] = _val
|
|
111
118
|
|
|
112
119
|
if settings.api_key:
|
|
113
120
|
mapping["HUD_API_KEY"] = settings.api_key
|
|
@@ -208,7 +215,7 @@ class AgentResponse(BaseModel):
|
|
|
208
215
|
tool_calls: list[MCPToolCall] = Field(default_factory=list)
|
|
209
216
|
done: bool = Field(default=False)
|
|
210
217
|
|
|
211
|
-
# --- TELEMETRY [
|
|
218
|
+
# --- TELEMETRY [hud.so] ---
|
|
212
219
|
# Responses
|
|
213
220
|
content: str | None = Field(default=None)
|
|
214
221
|
reasoning: str | None = Field(default=None)
|