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/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.command_example("export HUD_API_KEY=your-key-here", "Set your API key")
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")
@@ -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("[yellow]Please set HUD_API_KEY environment variable[/yellow]")
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
@@ -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 = Exception("Connection failed")
238
+ mock_client.initialize.side_effect = ConnectionError("Connection failed")
239
239
 
240
- result = await analyze_mcp_environment("test:latest")
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))
@@ -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 == "hud-my-test-project:dev"
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:
@@ -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
@@ -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
- clean_name = dir_name.replace("_", "-")
43
- return f"hud-{clean_name}:dev", "auto"
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:
@@ -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
- config = {"server": {"url": self.server_url}}
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(" 3. Set HUD_API_KEY for private environments")
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