hud-python 0.4.35__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/agents/tests/test_claude.py +32 -7
- hud/agents/tests/test_openai.py +29 -6
- hud/cli/__init__.py +228 -79
- hud/cli/build.py +26 -6
- hud/cli/dev.py +21 -40
- hud/cli/eval.py +96 -15
- hud/cli/flows/tasks.py +198 -65
- hud/cli/init.py +222 -629
- hud/cli/pull.py +6 -0
- hud/cli/push.py +11 -1
- 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 +166 -87
- hud/cli/rl/viewer.py +141 -0
- hud/cli/rl/wait_utils.py +89 -0
- 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/env_check.py +196 -0
- 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/cli/utils/source_hash.py +108 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +1 -1
- hud/clients/mcp_use.py +30 -7
- hud/datasets/parallel.py +3 -1
- hud/datasets/runner.py +4 -1
- hud/otel/config.py +1 -1
- hud/otel/context.py +40 -6
- hud/rl/buffer.py +3 -0
- hud/rl/tests/test_learner.py +1 -1
- hud/rl/vllm_adapter.py +1 -1
- hud/server/server.py +234 -7
- 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/settings.py +38 -0
- hud/shared/hints.py +2 -2
- 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.35.dist-info → hud_python-0.4.37.dist-info}/METADATA +43 -23
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/RECORD +63 -46
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
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
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Environment build checks and discovery helpers.
|
|
2
|
+
|
|
3
|
+
Shared utilities to:
|
|
4
|
+
- locate an environment directory related to a tasks file
|
|
5
|
+
- ensure the environment is built and up-to-date via source hash comparison
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from hud.utils.hud_console import hud_console
|
|
19
|
+
|
|
20
|
+
from .docker import require_docker_running
|
|
21
|
+
from .source_hash import compute_source_hash, list_source_files
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_generated_at(lock_data: dict[str, Any]) -> float | None:
|
|
25
|
+
"""Parse build.generatedAt into a POSIX timestamp (seconds).
|
|
26
|
+
|
|
27
|
+
Returns None if missing or unparsable.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
generated_at = (lock_data.get("build") or {}).get("generatedAt")
|
|
31
|
+
if not isinstance(generated_at, str):
|
|
32
|
+
return None
|
|
33
|
+
# Support ...Z and offsets
|
|
34
|
+
iso = generated_at.replace("Z", "+00:00")
|
|
35
|
+
dt = datetime.fromisoformat(iso)
|
|
36
|
+
if dt.tzinfo is None:
|
|
37
|
+
dt = dt.replace(tzinfo=UTC)
|
|
38
|
+
return dt.timestamp()
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_source_diffs(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, list[str]]:
|
|
44
|
+
"""Compute added/removed/modified files since last build using names + mtimes.
|
|
45
|
+
|
|
46
|
+
- added/removed are based on the stored build.sourceFiles list vs current file list
|
|
47
|
+
- modified is based on mtime newer than build.generatedAt for files present now
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
stored_files = (
|
|
51
|
+
(lock_data.get("build") or {}).get("sourceFiles") if isinstance(lock_data, dict) else []
|
|
52
|
+
)
|
|
53
|
+
stored_set = set(str(p) for p in (stored_files or []))
|
|
54
|
+
except Exception:
|
|
55
|
+
stored_set = set()
|
|
56
|
+
|
|
57
|
+
current_paths = list_source_files(env_dir)
|
|
58
|
+
# Normalize to POSIX-style relative strings
|
|
59
|
+
current_list = [str(p.resolve().relative_to(env_dir)).replace("\\", "/") for p in current_paths]
|
|
60
|
+
current_set = set(current_list)
|
|
61
|
+
|
|
62
|
+
added = sorted(current_set - stored_set)
|
|
63
|
+
removed = sorted(stored_set - current_set)
|
|
64
|
+
|
|
65
|
+
# Modified: mtime newer than build.generatedAt
|
|
66
|
+
modified: list[str] = []
|
|
67
|
+
built_ts = _parse_generated_at(lock_data)
|
|
68
|
+
if built_ts is not None:
|
|
69
|
+
for rel in sorted(current_set & (stored_set or current_set)):
|
|
70
|
+
with contextlib.suppress(Exception):
|
|
71
|
+
p = env_dir / rel
|
|
72
|
+
if p.exists() and p.stat().st_mtime > built_ts:
|
|
73
|
+
modified.append(rel)
|
|
74
|
+
|
|
75
|
+
return {"added": added, "removed": removed, "modified": modified}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_environment_dir(tasks_path: Path) -> Path | None:
|
|
79
|
+
"""Best-effort discovery of a nearby environment directory.
|
|
80
|
+
|
|
81
|
+
Preference order:
|
|
82
|
+
- directory with hud.lock.yaml
|
|
83
|
+
- directory that looks like an environment (Dockerfile + pyproject.toml)
|
|
84
|
+
- searches tasks dir, CWD, and a couple of parents
|
|
85
|
+
"""
|
|
86
|
+
from .environment import is_environment_directory # local import to avoid cycles
|
|
87
|
+
|
|
88
|
+
candidates: list[Path] = []
|
|
89
|
+
cwd = Path.cwd()
|
|
90
|
+
candidates.extend([tasks_path.parent, cwd])
|
|
91
|
+
|
|
92
|
+
# Add parents (up to 2 levels for each)
|
|
93
|
+
for base in list(candidates):
|
|
94
|
+
p = base
|
|
95
|
+
for _ in range(2):
|
|
96
|
+
p = p.parent
|
|
97
|
+
if p not in candidates:
|
|
98
|
+
candidates.append(p)
|
|
99
|
+
|
|
100
|
+
# Prefer those with hud.lock.yaml
|
|
101
|
+
for d in candidates:
|
|
102
|
+
if (d / "hud.lock.yaml").exists():
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
# Otherwise, find a plausible environment dir
|
|
106
|
+
for d in candidates:
|
|
107
|
+
try:
|
|
108
|
+
if is_environment_directory(d):
|
|
109
|
+
return d
|
|
110
|
+
except Exception as e:
|
|
111
|
+
hud_console.debug(f"Skipping path {d}: {e}")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
|
|
118
|
+
"""Ensure env has a lock and matches current sources via source hash.
|
|
119
|
+
|
|
120
|
+
If interactive is True, prompts to build/rebuild as needed. If False, only warns.
|
|
121
|
+
Returns the loaded lock data (empty dict if unreadable/missing).
|
|
122
|
+
"""
|
|
123
|
+
from hud.cli.build import build_environment # local import to avoid import cycles
|
|
124
|
+
|
|
125
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
126
|
+
if not lock_path.exists():
|
|
127
|
+
if interactive:
|
|
128
|
+
hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
|
|
129
|
+
ok = hud_console.confirm("Build the environment now (runs 'hud build')?", default=True)
|
|
130
|
+
if not ok:
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
require_docker_running()
|
|
133
|
+
build_environment(str(env_dir), platform="linux/amd64")
|
|
134
|
+
else:
|
|
135
|
+
hud_console.dim_info(
|
|
136
|
+
"Info",
|
|
137
|
+
"No hud.lock.yaml found nearby; skipping environment change checks.",
|
|
138
|
+
)
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
# Load lock file
|
|
142
|
+
try:
|
|
143
|
+
with open(lock_path) as f:
|
|
144
|
+
lock_data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
145
|
+
except Exception:
|
|
146
|
+
lock_data = {}
|
|
147
|
+
|
|
148
|
+
# Fast change detection: recompute source hash and compare
|
|
149
|
+
try:
|
|
150
|
+
current_hash = compute_source_hash(env_dir)
|
|
151
|
+
stored_hash = (
|
|
152
|
+
(lock_data.get("build") or {}).get("sourceHash")
|
|
153
|
+
if isinstance(lock_data, dict)
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
156
|
+
if stored_hash and current_hash and stored_hash != current_hash:
|
|
157
|
+
hud_console.warning("Environment sources changed since last build.")
|
|
158
|
+
|
|
159
|
+
# Show a brief diff summary to help users understand changes
|
|
160
|
+
diffs = _collect_source_diffs(env_dir, lock_data)
|
|
161
|
+
|
|
162
|
+
def _print_section(name: str, items: list[str]) -> None:
|
|
163
|
+
if not items:
|
|
164
|
+
return
|
|
165
|
+
# Limit output to avoid flooding the console
|
|
166
|
+
preview = items[:20]
|
|
167
|
+
more = len(items) - len(preview)
|
|
168
|
+
hud_console.section_title(name)
|
|
169
|
+
for rel in preview:
|
|
170
|
+
hud_console.dim_info("", rel)
|
|
171
|
+
if more > 0:
|
|
172
|
+
hud_console.dim_info("", f"... and {more} more")
|
|
173
|
+
|
|
174
|
+
_print_section("Modified files", diffs.get("modified", []))
|
|
175
|
+
_print_section("Added files", diffs.get("added", []))
|
|
176
|
+
_print_section("Removed files", diffs.get("removed", []))
|
|
177
|
+
|
|
178
|
+
if interactive:
|
|
179
|
+
if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
|
|
180
|
+
require_docker_running()
|
|
181
|
+
build_environment(str(env_dir), platform="linux/amd64")
|
|
182
|
+
with open(lock_path) as f:
|
|
183
|
+
lock_data = yaml.safe_load(f) or {}
|
|
184
|
+
else:
|
|
185
|
+
hud_console.hint("Continuing without rebuild; this may use an outdated image.")
|
|
186
|
+
else:
|
|
187
|
+
hud_console.hint("Run 'hud build' to update the image before proceeding.")
|
|
188
|
+
elif not stored_hash:
|
|
189
|
+
hud_console.dim_info(
|
|
190
|
+
"Info",
|
|
191
|
+
"No source hash in lock; rebuild to enable change checks.",
|
|
192
|
+
)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
hud_console.debug(f"Source hash check skipped: {e}")
|
|
195
|
+
|
|
196
|
+
return lock_data
|
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
|