hud-python 0.4.35__py3-none-any.whl → 0.4.36__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/tests/test_claude.py +32 -7
- hud/agents/tests/test_openai.py +29 -6
- hud/cli/__init__.py +209 -75
- hud/cli/build.py +9 -4
- hud/cli/dev.py +20 -39
- hud/cli/eval.py +3 -2
- hud/cli/flows/tasks.py +1 -0
- hud/cli/init.py +222 -629
- hud/cli/pull.py +6 -0
- hud/cli/push.py +2 -1
- hud/cli/rl/remote_runner.py +3 -1
- hud/cli/tests/test_build.py +3 -27
- hud/cli/tests/test_mcp_server.py +1 -12
- hud/cli/utils/config.py +85 -0
- hud/cli/utils/docker.py +21 -39
- hud/cli/utils/environment.py +4 -3
- hud/cli/utils/interactive.py +2 -1
- hud/cli/utils/local_runner.py +204 -0
- hud/cli/utils/metadata.py +3 -1
- hud/cli/utils/package_runner.py +292 -0
- hud/cli/utils/remote_runner.py +4 -1
- hud/clients/mcp_use.py +30 -7
- hud/datasets/parallel.py +3 -1
- hud/datasets/runner.py +4 -1
- hud/otel/context.py +38 -4
- hud/rl/buffer.py +3 -0
- hud/rl/tests/test_learner.py +1 -1
- hud/server/server.py +157 -1
- hud/settings.py +38 -0
- hud/shared/hints.py +1 -1
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.35.dist-info → hud_python-0.4.36.dist-info}/METADATA +30 -12
- {hud_python-0.4.35.dist-info → hud_python-0.4.36.dist-info}/RECORD +37 -34
- {hud_python-0.4.35.dist-info → hud_python-0.4.36.dist-info}/WHEEL +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.36.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.36.dist-info}/licenses/LICENSE +0 -0
hud/cli/pull.py
CHANGED
|
@@ -154,6 +154,9 @@ def pull_environment(
|
|
|
154
154
|
# Check for API key (not required for pulling, but good to inform)
|
|
155
155
|
if not settings.api_key:
|
|
156
156
|
hud_console.info("No HUD API key set (pulling from public registry)")
|
|
157
|
+
hud_console.info(
|
|
158
|
+
"Set it in your environment or run: hud set HUD_API_KEY=your-key-here"
|
|
159
|
+
)
|
|
157
160
|
|
|
158
161
|
lock_data = fetch_lock_from_registry(target)
|
|
159
162
|
|
|
@@ -166,6 +169,9 @@ def pull_environment(
|
|
|
166
169
|
hud_console.info(
|
|
167
170
|
"Not found in HUD registry (try setting HUD_API_KEY for private environments)" # noqa: E501
|
|
168
171
|
)
|
|
172
|
+
hud_console.info(
|
|
173
|
+
"Set it in your environment or run: hud set HUD_API_KEY=your-key-here"
|
|
174
|
+
)
|
|
169
175
|
else:
|
|
170
176
|
hud_console.info("Not found in HUD registry, treating as Docker image")
|
|
171
177
|
|
hud/cli/push.py
CHANGED
|
@@ -144,7 +144,7 @@ def push_environment(
|
|
|
144
144
|
hud_console.warning("A HUD API key is required to push environments.")
|
|
145
145
|
hud_console.info("\nTo get started:")
|
|
146
146
|
hud_console.info("1. Get your API key at: https://hud.so/settings")
|
|
147
|
-
hud_console.
|
|
147
|
+
hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
|
|
148
148
|
hud_console.command_example("hud push", "Try again")
|
|
149
149
|
hud_console.info("")
|
|
150
150
|
raise typer.Exit(1)
|
|
@@ -414,6 +414,7 @@ def push_environment(
|
|
|
414
414
|
hud_console.error("Authentication failed")
|
|
415
415
|
hud_console.info("Check your HUD_API_KEY is valid")
|
|
416
416
|
hud_console.info("Get a new key at: https://hud.so/settings")
|
|
417
|
+
hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
|
|
417
418
|
elif response.status_code == 403:
|
|
418
419
|
hud_console.error("Permission denied")
|
|
419
420
|
hud_console.info("You may not have access to push to this namespace")
|
hud/cli/rl/remote_runner.py
CHANGED
|
@@ -76,7 +76,9 @@ def run_remote_training(
|
|
|
76
76
|
|
|
77
77
|
if not settings.api_key:
|
|
78
78
|
hud_console.error("API key not found")
|
|
79
|
-
console.print(
|
|
79
|
+
console.print(
|
|
80
|
+
"[yellow]Set it in your environment or run: hud set HUD_API_KEY=your-key-here[/yellow]"
|
|
81
|
+
)
|
|
80
82
|
raise ValueError("API key not found")
|
|
81
83
|
|
|
82
84
|
# Step 1: CONFIRMATION - Load tasks and show example
|
hud/cli/tests/test_build.py
CHANGED
|
@@ -235,14 +235,10 @@ class TestAnalyzeMcpEnvironment:
|
|
|
235
235
|
# Setup mock client to fail
|
|
236
236
|
mock_client = mock.AsyncMock()
|
|
237
237
|
mock_client_class.return_value = mock_client
|
|
238
|
-
mock_client.initialize.side_effect =
|
|
238
|
+
mock_client.initialize.side_effect = ConnectionError("Connection failed")
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
assert result["success"] is False
|
|
243
|
-
assert result["toolCount"] == 0
|
|
244
|
-
assert "error" in result
|
|
245
|
-
assert "Connection failed" in result["error"]
|
|
240
|
+
with pytest.raises(ConnectionError):
|
|
241
|
+
await analyze_mcp_environment("test:latest")
|
|
246
242
|
|
|
247
243
|
@mock.patch("hud.cli.build.MCPClient")
|
|
248
244
|
async def test_analyze_verbose_mode(self, mock_client_class):
|
|
@@ -404,23 +400,3 @@ ENV API_KEY
|
|
|
404
400
|
|
|
405
401
|
with pytest.raises(typer.Exit):
|
|
406
402
|
build_environment(str(env_dir))
|
|
407
|
-
|
|
408
|
-
@mock.patch("hud.cli.build.build_docker_image")
|
|
409
|
-
@mock.patch("hud.cli.build.analyze_mcp_environment")
|
|
410
|
-
def test_build_environment_analysis_failure(self, mock_analyze, mock_build, tmp_path):
|
|
411
|
-
"""Test when MCP analysis fails."""
|
|
412
|
-
env_dir = tmp_path / "test-env"
|
|
413
|
-
env_dir.mkdir()
|
|
414
|
-
(env_dir / "pyproject.toml").write_text("[tool.hud]")
|
|
415
|
-
(env_dir / "Dockerfile").write_text("FROM python:3.11")
|
|
416
|
-
|
|
417
|
-
mock_build.return_value = True
|
|
418
|
-
mock_analyze.return_value = {
|
|
419
|
-
"success": False,
|
|
420
|
-
"error": "Connection failed",
|
|
421
|
-
"toolCount": 0,
|
|
422
|
-
"tools": [],
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
with pytest.raises(typer.Exit):
|
|
426
|
-
build_environment(str(env_dir))
|
hud/cli/tests/test_mcp_server.py
CHANGED
|
@@ -11,7 +11,6 @@ from hud.cli.dev import (
|
|
|
11
11
|
create_proxy_server,
|
|
12
12
|
get_docker_cmd,
|
|
13
13
|
get_image_name,
|
|
14
|
-
inject_supervisor,
|
|
15
14
|
run_mcp_dev_server,
|
|
16
15
|
update_pyproject_toml,
|
|
17
16
|
)
|
|
@@ -52,16 +51,6 @@ class TestDockerUtils:
|
|
|
52
51
|
cmd = get_docker_cmd("test-image:latest")
|
|
53
52
|
assert cmd is None
|
|
54
53
|
|
|
55
|
-
def test_inject_supervisor(self) -> None:
|
|
56
|
-
"""Test supervisor injection into Docker CMD."""
|
|
57
|
-
original_cmd = ["python", "-m", "server"]
|
|
58
|
-
modified = inject_supervisor(original_cmd)
|
|
59
|
-
|
|
60
|
-
assert modified[0] == "sh"
|
|
61
|
-
assert modified[1] == "-c"
|
|
62
|
-
assert "watchfiles" in modified[2]
|
|
63
|
-
assert "python -m server" in modified[2]
|
|
64
|
-
|
|
65
54
|
|
|
66
55
|
class TestImageResolution:
|
|
67
56
|
"""Test image name resolution."""
|
|
@@ -90,7 +79,7 @@ image = "my-project:latest"
|
|
|
90
79
|
test_dir.mkdir()
|
|
91
80
|
|
|
92
81
|
name, source = get_image_name(str(test_dir))
|
|
93
|
-
assert name == "
|
|
82
|
+
assert name == "my-test-project:dev"
|
|
94
83
|
assert source == "auto"
|
|
95
84
|
|
|
96
85
|
def test_update_pyproject_toml(self, tmp_path: Path) -> None:
|
hud/cli/utils/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_config_dir() -> Path:
|
|
7
|
+
"""Return the base HUD config directory in the user's home.
|
|
8
|
+
|
|
9
|
+
Uses ~/.hud across platforms for consistency with existing registry data.
|
|
10
|
+
"""
|
|
11
|
+
return Path.home() / ".hud"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_user_env_path() -> Path:
|
|
15
|
+
"""Return the path to the persistent user-level env file (~/.hud/.env)."""
|
|
16
|
+
return get_config_dir() / ".env"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_config_dir() -> Path:
|
|
20
|
+
"""Ensure the HUD config directory exists and return it."""
|
|
21
|
+
config_dir = get_config_dir()
|
|
22
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return config_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_env_file(contents: str) -> dict[str, str]:
|
|
27
|
+
"""Parse simple KEY=VALUE lines into a dict.
|
|
28
|
+
|
|
29
|
+
- Ignores blank lines and lines starting with '#'.
|
|
30
|
+
- Does not perform variable substitution or quoting.
|
|
31
|
+
"""
|
|
32
|
+
data: dict[str, str] = {}
|
|
33
|
+
for raw_line in contents.splitlines():
|
|
34
|
+
line = raw_line.strip()
|
|
35
|
+
if not line or line.startswith("#"):
|
|
36
|
+
continue
|
|
37
|
+
if "=" not in line:
|
|
38
|
+
continue
|
|
39
|
+
key, value = line.split("=", 1)
|
|
40
|
+
key = key.strip()
|
|
41
|
+
value = value.strip()
|
|
42
|
+
if key:
|
|
43
|
+
data[key] = value
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_env_file(env: dict[str, str]) -> str:
|
|
48
|
+
"""Render a dict of env values to KEY=VALUE lines with a header."""
|
|
49
|
+
header = [
|
|
50
|
+
"# HUD CLI persistent environment file",
|
|
51
|
+
"# Keys set via `hud set KEY=VALUE`",
|
|
52
|
+
"# This file is read after process env and project .env",
|
|
53
|
+
"# so project overrides take precedence over these defaults.",
|
|
54
|
+
"",
|
|
55
|
+
]
|
|
56
|
+
body = [f"{key}={env[key]}" for key in sorted(env.keys())]
|
|
57
|
+
return "\n".join([*header, *body, ""])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_env_file(path: Path | None = None) -> dict[str, str]:
|
|
61
|
+
"""Load env assignments from the given path (defaults to ~/.hud/.env)."""
|
|
62
|
+
env_path = path or get_user_env_path()
|
|
63
|
+
if not env_path.exists():
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
contents = env_path.read_text(encoding="utf-8")
|
|
67
|
+
except Exception:
|
|
68
|
+
return {}
|
|
69
|
+
return parse_env_file(contents)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_env_file(env: dict[str, str], path: Path | None = None) -> Path:
|
|
73
|
+
"""Write env assignments to the given path and return the path."""
|
|
74
|
+
ensure_config_dir()
|
|
75
|
+
env_path = path or get_user_env_path()
|
|
76
|
+
rendered = render_env_file(env)
|
|
77
|
+
env_path.write_text(rendered, encoding="utf-8")
|
|
78
|
+
return env_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_env_values(values: dict[str, str]) -> Path:
|
|
82
|
+
"""Persist provided KEY=VALUE pairs into ~/.hud/.env and return the path."""
|
|
83
|
+
current = load_env_file()
|
|
84
|
+
current.update(values)
|
|
85
|
+
return save_env_file(current)
|
hud/cli/utils/docker.py
CHANGED
|
@@ -36,45 +36,6 @@ def get_docker_cmd(image: str) -> list[str] | None:
|
|
|
36
36
|
return None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def inject_supervisor(cmd: list[str]) -> list[str]:
|
|
40
|
-
"""
|
|
41
|
-
Inject watchfiles CLI supervisor into a Docker CMD.
|
|
42
|
-
|
|
43
|
-
For shell commands, we inject before the last exec command.
|
|
44
|
-
For direct commands, we wrap the entire command.
|
|
45
|
-
|
|
46
|
-
Args:
|
|
47
|
-
cmd: Original Docker CMD
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
Modified CMD with watchfiles supervisor injected
|
|
51
|
-
"""
|
|
52
|
-
if not cmd:
|
|
53
|
-
return cmd
|
|
54
|
-
|
|
55
|
-
# Handle shell commands that might have background processes
|
|
56
|
-
if cmd[0] in ["sh", "bash"] and len(cmd) >= 3 and cmd[1] == "-c":
|
|
57
|
-
shell_cmd = cmd[2]
|
|
58
|
-
|
|
59
|
-
# Look for 'exec' in the shell command - this is the last command
|
|
60
|
-
if " exec " in shell_cmd:
|
|
61
|
-
# Replace only the exec'd command with watchfiles
|
|
62
|
-
parts = shell_cmd.rsplit(" exec ", 1)
|
|
63
|
-
if len(parts) == 2:
|
|
64
|
-
# Extract the actual command after exec
|
|
65
|
-
last_cmd = parts[1].strip()
|
|
66
|
-
# Use watchfiles with logs redirected to stderr (which won't interfere with MCP on stdout) # noqa: E501
|
|
67
|
-
new_shell_cmd = f"{parts[0]} exec watchfiles --verbose '{last_cmd}' /app/src"
|
|
68
|
-
return [cmd[0], cmd[1], new_shell_cmd]
|
|
69
|
-
else:
|
|
70
|
-
# No exec, the whole thing is the command
|
|
71
|
-
return ["sh", "-c", f"watchfiles --verbose '{shell_cmd}' /app/src"]
|
|
72
|
-
|
|
73
|
-
# Direct command - wrap with watchfiles
|
|
74
|
-
watchfiles_cmd = " ".join(cmd)
|
|
75
|
-
return ["sh", "-c", f"watchfiles --verbose '{watchfiles_cmd}' /app/src"]
|
|
76
|
-
|
|
77
|
-
|
|
78
39
|
def image_exists(image_name: str) -> bool:
|
|
79
40
|
"""Check if a Docker image exists locally."""
|
|
80
41
|
result = subprocess.run( # noqa: S603
|
|
@@ -121,6 +82,27 @@ def generate_container_name(identifier: str, prefix: str = "hud") -> str:
|
|
|
121
82
|
return f"{prefix}-{safe_name}"
|
|
122
83
|
|
|
123
84
|
|
|
85
|
+
def build_run_command(image: str, docker_args: list[str] | None = None) -> list[str]:
|
|
86
|
+
"""Construct a standard docker run command used across CLI commands.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
image: Docker image name to run
|
|
90
|
+
docker_args: Additional docker args to pass before the image
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The docker run command list
|
|
94
|
+
"""
|
|
95
|
+
args = docker_args or []
|
|
96
|
+
return [
|
|
97
|
+
"docker",
|
|
98
|
+
"run",
|
|
99
|
+
"--rm",
|
|
100
|
+
"-i",
|
|
101
|
+
*args,
|
|
102
|
+
image,
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
124
106
|
def _emit_docker_hints(error_text: str) -> None:
|
|
125
107
|
"""Parse common Docker connectivity errors and print platform-specific hints."""
|
|
126
108
|
from hud.utils.hud_console import hud_console
|
hud/cli/utils/environment.py
CHANGED
|
@@ -33,14 +33,15 @@ def get_image_name(directory: str | Path, image_override: str | None = None) ->
|
|
|
33
33
|
except Exception:
|
|
34
34
|
hud_console.error("Error loading pyproject.toml")
|
|
35
35
|
|
|
36
|
-
# Auto-generate with :dev tag
|
|
36
|
+
# Auto-generate with :dev tag (replace underscores with hyphens)
|
|
37
37
|
dir_path = Path(directory).resolve() # Get absolute path first
|
|
38
38
|
dir_name = dir_path.name
|
|
39
39
|
if not dir_name or dir_name == ".":
|
|
40
40
|
# If we're in root or have empty name, use parent directory
|
|
41
41
|
dir_name = dir_path.parent.name
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Replace underscores with hyphens for Docker image names
|
|
43
|
+
dir_name = dir_name.replace("_", "-")
|
|
44
|
+
return f"{dir_name}:dev", "auto"
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
|
hud/cli/utils/interactive.py
CHANGED
|
@@ -39,7 +39,8 @@ class InteractiveMCPTester:
|
|
|
39
39
|
"""Connect to the MCP server."""
|
|
40
40
|
try:
|
|
41
41
|
# Create MCP config for HTTP transport
|
|
42
|
-
|
|
42
|
+
# Note: We explicitly set auth to None to prevent OAuth discovery attempts
|
|
43
|
+
config = {"server": {"url": self.server_url, "auth": None}}
|
|
43
44
|
|
|
44
45
|
self.client = MCPClient(
|
|
45
46
|
mcp_config=config,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Run local Python files as MCP servers with reload support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from hud.utils.hud_console import HUDConsole
|
|
14
|
+
|
|
15
|
+
hud_console = HUDConsole()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def run_with_reload(
|
|
19
|
+
server_file: str,
|
|
20
|
+
transport: str = "stdio",
|
|
21
|
+
port: int | None = None,
|
|
22
|
+
verbose: bool = False,
|
|
23
|
+
extra_args: list[str] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Run Python server with auto-reload functionality."""
|
|
26
|
+
try:
|
|
27
|
+
import watchfiles
|
|
28
|
+
except ImportError:
|
|
29
|
+
hud_console.error(
|
|
30
|
+
"watchfiles is required for --reload. Install with: pip install watchfiles"
|
|
31
|
+
)
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
# Parse server file path
|
|
35
|
+
if ":" in server_file:
|
|
36
|
+
file_path, _ = server_file.split(":", 1)
|
|
37
|
+
else:
|
|
38
|
+
file_path = server_file
|
|
39
|
+
|
|
40
|
+
file_path = Path(file_path).resolve()
|
|
41
|
+
if not file_path.exists():
|
|
42
|
+
hud_console.error(f"Server file not found: {file_path}")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
# Watch the directory containing the server file (like uvicorn)
|
|
46
|
+
watch_dir = file_path.parent
|
|
47
|
+
|
|
48
|
+
# Build command
|
|
49
|
+
cmd = [sys.executable, "-m", "fastmcp.cli", "run", server_file]
|
|
50
|
+
if transport:
|
|
51
|
+
cmd.extend(["--transport", transport])
|
|
52
|
+
if port and transport == "http":
|
|
53
|
+
cmd.extend(["--port", str(port)])
|
|
54
|
+
cmd.append("--no-banner")
|
|
55
|
+
if verbose:
|
|
56
|
+
cmd.extend(["--log-level", "DEBUG"])
|
|
57
|
+
if extra_args:
|
|
58
|
+
cmd.append("--")
|
|
59
|
+
cmd.extend(extra_args)
|
|
60
|
+
|
|
61
|
+
# Filter for Python files and important config files
|
|
62
|
+
def should_reload(change: watchfiles.Change, path: str) -> bool:
|
|
63
|
+
path_obj = Path(path)
|
|
64
|
+
# Ignore common non-code files
|
|
65
|
+
if any(part.startswith(".") for part in path_obj.parts):
|
|
66
|
+
return False
|
|
67
|
+
if "__pycache__" in path_obj.parts:
|
|
68
|
+
return False
|
|
69
|
+
return path_obj.suffix in {".py", ".json", ".toml", ".yaml", ".yml"}
|
|
70
|
+
|
|
71
|
+
process = None
|
|
72
|
+
|
|
73
|
+
async def run_server() -> int:
|
|
74
|
+
"""Run the server process."""
|
|
75
|
+
nonlocal process
|
|
76
|
+
|
|
77
|
+
# For stdio transport, we need special handling to preserve stdout
|
|
78
|
+
if transport == "stdio":
|
|
79
|
+
# All server logs must go to stderr
|
|
80
|
+
env = os.environ.copy()
|
|
81
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
82
|
+
|
|
83
|
+
process = await asyncio.create_subprocess_exec(
|
|
84
|
+
*cmd,
|
|
85
|
+
stdin=sys.stdin,
|
|
86
|
+
stdout=sys.stdout, # Direct passthrough for MCP
|
|
87
|
+
stderr=sys.stderr, # Logs and errors
|
|
88
|
+
env=env,
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
# For HTTP transport, normal subprocess
|
|
92
|
+
process = await asyncio.create_subprocess_exec(
|
|
93
|
+
*cmd,
|
|
94
|
+
stdin=sys.stdin,
|
|
95
|
+
stdout=sys.stdout,
|
|
96
|
+
stderr=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return await process.wait()
|
|
100
|
+
|
|
101
|
+
async def stop_server() -> None:
|
|
102
|
+
"""Stop the server process gracefully."""
|
|
103
|
+
if process and process.returncode is None:
|
|
104
|
+
if sys.platform == "win32":
|
|
105
|
+
# Windows: terminate directly
|
|
106
|
+
process.terminate()
|
|
107
|
+
else:
|
|
108
|
+
# Unix: send SIGINT for hot reload
|
|
109
|
+
process.send_signal(signal.SIGINT)
|
|
110
|
+
|
|
111
|
+
# Wait for graceful shutdown
|
|
112
|
+
try:
|
|
113
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
114
|
+
except TimeoutError:
|
|
115
|
+
# Force kill if not responding
|
|
116
|
+
if verbose:
|
|
117
|
+
hud_console.warning("Server didn't stop gracefully, forcing shutdown...")
|
|
118
|
+
process.kill()
|
|
119
|
+
await process.wait()
|
|
120
|
+
|
|
121
|
+
# Initial server start
|
|
122
|
+
server_task = asyncio.create_task(run_server())
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Watch for file changes
|
|
126
|
+
async for changes in watchfiles.awatch(watch_dir):
|
|
127
|
+
# Check if any change should trigger reload
|
|
128
|
+
if any(should_reload(change, path) for change, path in changes):
|
|
129
|
+
changed_files = [
|
|
130
|
+
path for _, path in changes if should_reload(watchfiles.Change.modified, path)
|
|
131
|
+
]
|
|
132
|
+
if verbose:
|
|
133
|
+
for file in changed_files[:3]: # Show first 3 files
|
|
134
|
+
hud_console.info(f"File changed: {Path(file).relative_to(watch_dir)}")
|
|
135
|
+
if len(changed_files) > 3:
|
|
136
|
+
hud_console.info(f"... and {len(changed_files) - 3} more files")
|
|
137
|
+
|
|
138
|
+
hud_console.info("🔄 Reloading server...")
|
|
139
|
+
|
|
140
|
+
# Stop current server
|
|
141
|
+
await stop_server()
|
|
142
|
+
|
|
143
|
+
# Small delay to ensure clean restart
|
|
144
|
+
await asyncio.sleep(0.1)
|
|
145
|
+
|
|
146
|
+
# Start new server
|
|
147
|
+
server_task = asyncio.create_task(run_server())
|
|
148
|
+
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
hud_console.info("\n👋 Shutting down...")
|
|
151
|
+
await stop_server()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
if verbose:
|
|
154
|
+
hud_console.error(f"Reload error: {e}")
|
|
155
|
+
await stop_server()
|
|
156
|
+
raise
|
|
157
|
+
finally:
|
|
158
|
+
# Ensure server is stopped
|
|
159
|
+
if server_task and not server_task.done():
|
|
160
|
+
server_task.cancel()
|
|
161
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
162
|
+
await server_task
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_local_server(
|
|
166
|
+
server_file: str,
|
|
167
|
+
transport: str = "stdio",
|
|
168
|
+
port: int | None = None,
|
|
169
|
+
verbose: bool = False,
|
|
170
|
+
reload: bool = False,
|
|
171
|
+
extra_args: list[str] | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Run a local Python file as an MCP server."""
|
|
174
|
+
if reload:
|
|
175
|
+
# Run with reload support
|
|
176
|
+
asyncio.run(
|
|
177
|
+
run_with_reload(
|
|
178
|
+
server_file,
|
|
179
|
+
transport=transport,
|
|
180
|
+
port=port,
|
|
181
|
+
verbose=verbose,
|
|
182
|
+
extra_args=extra_args,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
# Run directly without reload
|
|
187
|
+
cmd = [sys.executable, "-m", "fastmcp.cli", "run", server_file]
|
|
188
|
+
if transport:
|
|
189
|
+
cmd.extend(["--transport", transport])
|
|
190
|
+
if port and transport == "http":
|
|
191
|
+
cmd.extend(["--port", str(port)])
|
|
192
|
+
cmd.append("--no-banner")
|
|
193
|
+
if verbose:
|
|
194
|
+
cmd.extend(["--log-level", "DEBUG"])
|
|
195
|
+
if extra_args:
|
|
196
|
+
cmd.append("--")
|
|
197
|
+
cmd.extend(extra_args)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
result = subprocess.run(cmd) # noqa: S603
|
|
201
|
+
sys.exit(result.returncode)
|
|
202
|
+
except KeyboardInterrupt:
|
|
203
|
+
hud_console.info("\n👋 Shutting down...")
|
|
204
|
+
sys.exit(0)
|
hud/cli/utils/metadata.py
CHANGED
|
@@ -161,7 +161,9 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
|
|
|
161
161
|
console.print(f" 1. Pull it first: [cyan]hud pull {reference}[/cyan]")
|
|
162
162
|
console.print(f" 2. Run live analysis: [cyan]hud analyze {reference} --live[/cyan]")
|
|
163
163
|
if not settings.api_key:
|
|
164
|
-
console.print(
|
|
164
|
+
console.print(
|
|
165
|
+
" 3. Set HUD_API_KEY in your environment or run: hud set HUD_API_KEY=your-key-here"
|
|
166
|
+
)
|
|
165
167
|
return
|
|
166
168
|
|
|
167
169
|
# Convert lock data to analysis format
|