hud-python 0.4.56__py3-none-any.whl → 0.4.58__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.

@@ -0,0 +1,367 @@
1
+ """Tests for the convert command."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+ import typer
9
+
10
+ from hud.cli.flows.tasks import convert_tasks_to_remote
11
+ from hud.types import Task
12
+
13
+
14
+ class TestConvertCommand:
15
+ """Test the convert command functionality."""
16
+
17
+ @pytest.fixture
18
+ def temp_tasks_file(self, tmp_path):
19
+ """Create a temporary tasks file."""
20
+ tasks = [
21
+ {
22
+ "prompt": "Test task 1",
23
+ "mcp_config": {
24
+ "local": {
25
+ "command": "docker",
26
+ "args": ["run", "--rm", "-i", "test-image:latest"],
27
+ }
28
+ },
29
+ }
30
+ ]
31
+ tasks_file = tmp_path / "tasks.json"
32
+ tasks_file.write_text(json.dumps(tasks))
33
+ return tasks_file
34
+
35
+ @pytest.fixture
36
+ def mock_env_dir(self, tmp_path):
37
+ """Create a mock environment directory with lock file."""
38
+ env_dir = tmp_path / "env"
39
+ env_dir.mkdir()
40
+
41
+ # Create lock file
42
+ lock_data = {
43
+ "images": {
44
+ "remote": "registry.hud.so/test-org/test-env:v1.0.0",
45
+ "local": "test-env:latest",
46
+ }
47
+ }
48
+ lock_file = env_dir / "hud.lock.yaml"
49
+ import yaml
50
+
51
+ lock_file.write_text(yaml.dump(lock_data))
52
+
53
+ return env_dir
54
+
55
+ @patch("hud.cli.flows.tasks._derive_remote_image")
56
+ @patch("hud.cli.flows.tasks._ensure_pushed")
57
+ @patch("hud.cli.flows.tasks.find_environment_dir")
58
+ @patch("hud.cli.flows.tasks.load_tasks")
59
+ @patch("hud.settings.settings")
60
+ def test_convert_tasks_basic(
61
+ self,
62
+ mock_settings,
63
+ mock_load_tasks,
64
+ mock_find_env,
65
+ mock_ensure_pushed,
66
+ mock_derive_remote,
67
+ temp_tasks_file,
68
+ mock_env_dir,
69
+ ):
70
+ """Test basic task conversion from local to remote."""
71
+ # Setup mocks
72
+ mock_settings.api_key = "test-api-key"
73
+ mock_find_env.return_value = mock_env_dir
74
+
75
+ # Mock the push check to return updated lock data
76
+ mock_ensure_pushed.return_value = {
77
+ "images": {
78
+ "remote": "registry.hud.so/test-org/test-env:v1.0.0",
79
+ "local": "test-env:v1.0.0",
80
+ }
81
+ }
82
+
83
+ # Mock derive remote image
84
+ mock_derive_remote.return_value = "registry.hud.so/test-org/test-env:v1.0.0"
85
+
86
+ task = Task(
87
+ prompt="Test task",
88
+ mcp_config={
89
+ "local": {"command": "docker", "args": ["run", "--rm", "-i", "test-image:latest"]}
90
+ },
91
+ )
92
+ raw_task = {
93
+ "prompt": "Test task",
94
+ "mcp_config": {
95
+ "local": {"command": "docker", "args": ["run", "--rm", "-i", "test-image:latest"]}
96
+ },
97
+ }
98
+
99
+ mock_load_tasks.side_effect = [[task], [raw_task]]
100
+
101
+ # Run conversion
102
+ result_path = convert_tasks_to_remote(str(temp_tasks_file))
103
+
104
+ # Check result
105
+ assert result_path.endswith("remote_tasks.json")
106
+ assert Path(result_path).exists()
107
+
108
+ # Verify converted content
109
+ with open(result_path) as f:
110
+ converted_tasks = json.load(f)
111
+
112
+ assert len(converted_tasks) == 1
113
+ assert "hud" in converted_tasks[0]["mcp_config"]
114
+ assert converted_tasks[0]["mcp_config"]["hud"]["url"] == "https://mcp.hud.so/v3/mcp"
115
+
116
+ @patch("hud.settings.settings")
117
+ def test_convert_missing_api_key(self, mock_settings, temp_tasks_file):
118
+ """Test that conversion fails without API key."""
119
+ mock_settings.api_key = ""
120
+
121
+ with pytest.raises(typer.Exit):
122
+ convert_tasks_to_remote(str(temp_tasks_file))
123
+
124
+ @patch("hud.cli.flows.tasks.find_environment_dir")
125
+ @patch("hud.cli.flows.tasks.load_tasks")
126
+ @patch("hud.settings.settings")
127
+ def test_convert_already_remote(
128
+ self, mock_settings, mock_load_tasks, mock_find_env, temp_tasks_file
129
+ ):
130
+ """Test that already remote tasks are not converted again."""
131
+ mock_settings.api_key = "test-api-key"
132
+ mock_find_env.return_value = None # No env dir needed for remote tasks
133
+
134
+ # Create task that's already remote
135
+ task = Task(
136
+ prompt="Test task",
137
+ mcp_config={
138
+ "remote": {
139
+ "url": "https://mcp.hud.so",
140
+ "headers": {"Mcp-Image": "registry.hud.so/test/image:v1"},
141
+ }
142
+ },
143
+ )
144
+
145
+ mock_load_tasks.return_value = [task]
146
+
147
+ # Should return original path without modification
148
+ result_path = convert_tasks_to_remote(str(temp_tasks_file))
149
+ assert result_path == str(temp_tasks_file)
150
+
151
+ @patch("hud.cli.flows.tasks.find_environment_dir")
152
+ @patch("hud.cli.flows.tasks.load_tasks")
153
+ @patch("hud.settings.settings")
154
+ def test_convert_no_environment(
155
+ self, mock_settings, mock_load_tasks, mock_find_env, temp_tasks_file
156
+ ):
157
+ """Test that conversion fails when no environment is found."""
158
+ mock_settings.api_key = "test-api-key"
159
+ mock_find_env.return_value = None
160
+
161
+ task = Task(
162
+ prompt="Test task",
163
+ mcp_config={
164
+ "local": {"command": "docker", "args": ["run", "--rm", "-i", "test-image:latest"]}
165
+ },
166
+ )
167
+
168
+ mock_load_tasks.return_value = [task]
169
+
170
+ with pytest.raises(typer.Exit):
171
+ convert_tasks_to_remote(str(temp_tasks_file))
172
+
173
+ @patch("hud.utils.hud_console.hud_console.confirm")
174
+ @patch("hud.cli.flows.tasks._derive_remote_image")
175
+ @patch("hud.cli.flows.tasks._ensure_pushed")
176
+ @patch("hud.cli.flows.tasks.find_environment_dir")
177
+ @patch("hud.cli.flows.tasks.load_tasks")
178
+ @patch("hud.settings.settings")
179
+ def test_convert_with_env_vars(
180
+ self,
181
+ mock_settings,
182
+ mock_load_tasks,
183
+ mock_find_env,
184
+ mock_ensure_pushed,
185
+ mock_derive_remote,
186
+ mock_confirm,
187
+ temp_tasks_file,
188
+ mock_env_dir,
189
+ ):
190
+ """Test conversion includes environment variables as headers."""
191
+ mock_settings.api_key = "test-api-key"
192
+ mock_find_env.return_value = mock_env_dir
193
+ mock_confirm.return_value = True # Always confirm in tests
194
+
195
+ # Mock the push check to return updated lock data
196
+ mock_ensure_pushed.return_value = {
197
+ "images": {
198
+ "remote": "registry.hud.so/test-org/test-env:v1.0.0",
199
+ "local": "test-env:v1.0.0",
200
+ }
201
+ }
202
+
203
+ # Mock derive remote image
204
+ mock_derive_remote.return_value = "registry.hud.so/test-org/test-env:v1.0.0"
205
+
206
+ # Add .env file with API keys
207
+ env_file = mock_env_dir / ".env"
208
+ env_file.write_text("OPENAI_API_KEY=sk-test123\nANTHROPIC_API_KEY=sk-ant456")
209
+
210
+ task = Task(
211
+ prompt="Test task",
212
+ mcp_config={
213
+ "local": {
214
+ "command": "docker",
215
+ "args": ["run", "--rm", "-i", "-e", "OPENAI_API_KEY", "test-image:latest"],
216
+ }
217
+ },
218
+ )
219
+ raw_task = task.model_dump()
220
+
221
+ mock_load_tasks.side_effect = [[task], [raw_task]]
222
+
223
+ # Run conversion
224
+ result_path = convert_tasks_to_remote(str(temp_tasks_file))
225
+
226
+ # Verify headers include env vars
227
+ with open(result_path) as f:
228
+ converted_tasks = json.load(f)
229
+
230
+ headers = converted_tasks[0]["mcp_config"]["hud"]["headers"]
231
+ assert "Env-Openai-Api-Key" in headers
232
+ assert headers["Env-Openai-Api-Key"] == "${OPENAI_API_KEY}"
233
+
234
+
235
+ class TestConvertHelperFunctions:
236
+ """Test helper functions used by convert command."""
237
+
238
+ def test_env_var_to_header_key(self):
239
+ """Test environment variable name conversion to header format."""
240
+ from hud.cli.flows.tasks import _env_var_to_header_key
241
+
242
+ assert _env_var_to_header_key("OPENAI_API_KEY") == "Env-Openai-Api-Key"
243
+ assert _env_var_to_header_key("ANTHROPIC_API_KEY") == "Env-Anthropic-Api-Key"
244
+ assert _env_var_to_header_key("SIMPLE") == "Env-Simple"
245
+ assert _env_var_to_header_key("MULTIPLE_WORD_VAR") == "Env-Multiple-Word-Var"
246
+
247
+ def test_extract_dotenv_api_key_vars(self):
248
+ """Test extraction of API-like variables from .env file."""
249
+ # Create test env directory with .env file
250
+ import tempfile
251
+
252
+ from hud.cli.flows.tasks import _extract_dotenv_api_key_vars
253
+
254
+ with tempfile.TemporaryDirectory() as tmpdir:
255
+ env_dir = Path(tmpdir)
256
+ env_file = env_dir / ".env"
257
+ env_file.write_text("""
258
+ # Test .env file
259
+ OPENAI_API_KEY=sk-test123
260
+ ANTHROPIC_API_KEY=sk-ant456
261
+ SOME_TOKEN=abc123
262
+ CLIENT_SECRET=secret789
263
+ USER_PASSWORD=pass123
264
+ REGULAR_VAR=not_included
265
+ HUD_API_URL=https://api.hud.so
266
+ """)
267
+
268
+ result = _extract_dotenv_api_key_vars(env_dir)
269
+
270
+ # Should include only API-like variables
271
+ assert "OPENAI_API_KEY" in result
272
+ assert "ANTHROPIC_API_KEY" in result
273
+ assert "SOME_TOKEN" in result
274
+ assert "CLIENT_SECRET" in result
275
+ assert "USER_PASSWORD" in result
276
+ assert "REGULAR_VAR" not in result
277
+ assert "HUD_API_URL" in result # API in name, so it's included
278
+
279
+ def test_is_remote_url(self):
280
+ """Test remote URL detection."""
281
+ from hud.cli.flows.tasks import _is_remote_url
282
+
283
+ # This function matches URLs with domain names (not localhost or IPs)
284
+ assert _is_remote_url("https://mcp.hud.so")
285
+ assert _is_remote_url("http://mcp.hud.so")
286
+ assert _is_remote_url("https://mcp.hud.so/some/path")
287
+ assert _is_remote_url("https://example.com") # Also matches
288
+ assert not _is_remote_url("http://localhost:8000") # localhost doesn't match
289
+ assert not _is_remote_url("file:///path/to/file") # file:// doesn't match
290
+
291
+ def test_extract_env_vars_from_docker_args(self):
292
+ """Test extraction of environment variables from docker arguments."""
293
+ from hud.cli.flows.tasks import _extract_env_vars_from_docker_args
294
+
295
+ # Test with various docker arg formats
296
+ args = [
297
+ "run",
298
+ "--rm",
299
+ "-i",
300
+ "-e",
301
+ "VAR1",
302
+ "-e",
303
+ "VAR2=value",
304
+ "--env",
305
+ "VAR3",
306
+ "--env=VAR4",
307
+ # Note: -eFOO compact form is not supported by the implementation
308
+ "--env-file",
309
+ ".env",
310
+ "-p",
311
+ "8080:80",
312
+ ]
313
+
314
+ result = _extract_env_vars_from_docker_args(args)
315
+
316
+ assert "VAR1" in result
317
+ assert "VAR2" in result
318
+ assert "VAR3" in result
319
+ assert "VAR4" in result
320
+ # FOO is not extracted because -eFOO compact form is not supported
321
+ assert len(result) == 4
322
+
323
+ def test_derive_remote_image(self):
324
+ """Test deriving remote image from lock data."""
325
+ from hud.cli.flows.tasks import _derive_remote_image
326
+
327
+ # The function derives remote image from images.local, not images.remote
328
+ lock_data = {"images": {"local": "test-env:v1.0.0"}}
329
+ result = _derive_remote_image(lock_data)
330
+ assert result == "test-env:v1.0.0"
331
+
332
+ # Test fallback to legacy format
333
+ lock_data = {
334
+ "image": "test-org/test-env:v1.0.0",
335
+ }
336
+ result = _derive_remote_image(lock_data)
337
+ assert result == "test-org/test-env:v1.0.0"
338
+
339
+ def test_extract_vars_from_task_configs(self):
340
+ """Test extraction of env vars from task configurations."""
341
+ from hud.cli.flows.tasks import _extract_vars_from_task_configs
342
+
343
+ raw_tasks = [
344
+ {
345
+ "prompt": "Task 1",
346
+ "mcp_config": {
347
+ "local": {"command": "docker", "args": ["run", "-e", "API_KEY1", "image1"]}
348
+ },
349
+ },
350
+ {
351
+ "prompt": "Task 2",
352
+ "mcp_config": {
353
+ "local": {
354
+ "command": "docker",
355
+ "args": ["run", "-e", "API_KEY2", "--env", "API_KEY3", "image2"],
356
+ }
357
+ },
358
+ },
359
+ {"prompt": "Task 3", "mcp_config": {"remote": {"url": "https://mcp.hud.so"}}},
360
+ ]
361
+
362
+ result = _extract_vars_from_task_configs(raw_tasks)
363
+
364
+ assert "API_KEY1" in result
365
+ assert "API_KEY2" in result
366
+ assert "API_KEY3" in result
367
+ assert len(result) == 3
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  import contextlib
19
19
  import json
20
+ import logging
20
21
  import os
21
22
  import time
22
23
  from pathlib import Path
@@ -27,6 +28,9 @@ from packaging import version
27
28
 
28
29
  from hud.utils.hud_console import HUDConsole
29
30
 
31
+ # Logger for version checking
32
+ logger = logging.getLogger(__name__)
33
+
30
34
  # Cache location for version check data
31
35
  CACHE_DIR = Path.home() / ".hud" / ".cache"
32
36
  VERSION_CACHE_FILE = CACHE_DIR / "version_check.json"
@@ -218,7 +222,7 @@ def display_update_prompt(console: HUDConsole | None = None) -> None:
218
222
  console: HUDConsole instance for output. If None, creates a new one.
219
223
  """
220
224
  if console is None:
221
- console = HUDConsole()
225
+ console = HUDConsole(logger=logger)
222
226
 
223
227
  try:
224
228
  info = check_for_updates()
@@ -231,11 +235,8 @@ def display_update_prompt(console: HUDConsole | None = None) -> None:
231
235
  f" Run: [bold yellow]uv tool upgrade hud-python[/bold yellow] to update"
232
236
  )
233
237
 
234
- # Display as a subtle but noticeable panel
235
- console._stdout_console.print(
236
- f"\n[yellow]{update_msg}[/yellow]\n",
237
- highlight=False,
238
- )
238
+ # Display using console info
239
+ console.info(f"[yellow]{update_msg}[/yellow]")
239
240
  except Exception: # noqa: S110
240
241
  # Never let version checking disrupt the user's workflow
241
242
  pass
hud/clients/base.py CHANGED
@@ -104,6 +104,7 @@ class BaseHUDClient(AgentMCPClient):
104
104
 
105
105
  self._initialized = False
106
106
  self._telemetry_data = {} # Initialize telemetry data
107
+ self._cached_resources: list[types.Resource] = [] # Cache for resources
107
108
 
108
109
  if self.verbose:
109
110
  self._setup_verbose_logging()
@@ -139,7 +140,7 @@ class BaseHUDClient(AgentMCPClient):
139
140
  raise HudAuthenticationError(
140
141
  f'Sending authorization "{headers.get("Authorization", "")}", which may'
141
142
  " be incomplete. Ensure HUD_API_KEY environment variable is set or send it"
142
- " as a header. You can get an API key at https://hud.so"
143
+ " as a header. You can get an API key at https://hud.ai"
143
144
  )
144
145
  # Subclasses implement connection
145
146
  await self._connect(self._mcp_config)
@@ -170,6 +171,7 @@ class BaseHUDClient(AgentMCPClient):
170
171
  if self._initialized:
171
172
  await self._disconnect()
172
173
  self._initialized = False
174
+ self._cached_resources.clear()
173
175
  hud_console.info("Environment Shutdown completed")
174
176
  else:
175
177
  hud_console.debug("Client was not initialized, skipping disconnect")
@@ -211,9 +213,22 @@ class BaseHUDClient(AgentMCPClient):
211
213
  """List all available tools."""
212
214
  raise NotImplementedError
213
215
 
214
- @abstractmethod
215
216
  async def list_resources(self) -> list[types.Resource]:
216
- """List all available resources."""
217
+ """List all available resources.
218
+
219
+ Uses cached resources if available, otherwise fetches from the server.
220
+
221
+ Returns:
222
+ List of available resources.
223
+ """
224
+ # If cache is empty, populate it
225
+ if not self._cached_resources:
226
+ self._cached_resources = await self._list_resources_impl()
227
+ return self._cached_resources
228
+
229
+ @abstractmethod
230
+ async def _list_resources_impl(self) -> list[types.Resource]:
231
+ """Implementation-specific resource listing. Subclasses must implement this."""
217
232
  raise NotImplementedError
218
233
 
219
234
  @abstractmethod
@@ -270,6 +285,17 @@ class BaseHUDClient(AgentMCPClient):
270
285
  async def _fetch_telemetry(self) -> None:
271
286
  """Common telemetry fetching for all hud clients."""
272
287
  try:
288
+ # Get resources (will use cache if available, otherwise fetch)
289
+ resources = await self.list_resources()
290
+ telemetry_available = any(
291
+ str(resource.uri) == "telemetry://live" for resource in resources
292
+ )
293
+
294
+ if not telemetry_available:
295
+ if self.verbose:
296
+ hud_console.debug("Telemetry resource not available from server")
297
+ return
298
+
273
299
  # Try to read telemetry resource directly
274
300
  result = await self.read_resource("telemetry://live")
275
301
  if result and result.contents:
hud/clients/fastmcp.py CHANGED
@@ -95,7 +95,7 @@ class FastMCPHUDClient(BaseHUDClient):
95
95
  raise RuntimeError(
96
96
  "Authentication failed for HUD API. "
97
97
  "Please ensure your HUD_API_KEY environment variable is set correctly." # noqa: E501
98
- "You can get an API key at https://hud.so"
98
+ "You can get an API key at https://hud.ai"
99
99
  ) from e
100
100
  # Generic 401 error
101
101
  raise RuntimeError(
@@ -143,8 +143,8 @@ class FastMCPHUDClient(BaseHUDClient):
143
143
  structuredContent=result.structured_content,
144
144
  )
145
145
 
146
- async def list_resources(self) -> list[types.Resource]:
147
- """List all available resources."""
146
+ async def _list_resources_impl(self) -> list[types.Resource]:
147
+ """Implementation of resource listing for FastMCP client."""
148
148
  if self._client is None:
149
149
  raise ValueError("Client is not connected, call initialize() first")
150
150
  return await self._client.list_resources()
hud/clients/mcp_use.py CHANGED
@@ -243,8 +243,8 @@ class MCPUseHUDClient(BaseHUDClient):
243
243
  structuredContent=result.structuredContent,
244
244
  )
245
245
 
246
- async def list_resources(self) -> list[types.Resource]:
247
- """List all available resources."""
246
+ async def _list_resources_impl(self) -> list[types.Resource]:
247
+ """Implementation of resource listing for MCP-use client."""
248
248
  if self._client is None or not self._sessions:
249
249
  raise ValueError("Client is not connected, call initialize() first")
250
250
 
@@ -35,9 +35,15 @@ class MockClient(BaseHUDClient):
35
35
  raise RuntimeError("Not connected")
36
36
  return self._mock_tools
37
37
 
38
- async def list_resources(self) -> list[types.Resource]:
39
- """Minimal list_resources for protocol satisfaction in tests."""
40
- return []
38
+ async def _list_resources_impl(self) -> list[types.Resource]:
39
+ """Minimal resource listing implementation for tests."""
40
+ from pydantic import AnyUrl
41
+
42
+ return [
43
+ types.Resource(
44
+ uri=AnyUrl("telemetry://live"), name="telemetry", description="Live telemetry data"
45
+ )
46
+ ]
41
47
 
42
48
  async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
43
49
  if tool_call.name == "test_tool":
hud/otel/config.py CHANGED
@@ -113,7 +113,7 @@ def configure_telemetry(
113
113
  # Error if no exporters are configured
114
114
  raise ValueError(
115
115
  "No telemetry backend configured. Either:\n"
116
- "1. Set HUD_API_KEY environment variable for HUD telemetry (https://hud.so)\n"
116
+ "1. Set HUD_API_KEY environment variable for HUD telemetry (https://hud.ai)\n"
117
117
  "2. Use enable_otlp=True with configure_telemetry() for alternative backends (e.g., Jaeger)\n" # noqa: E501
118
118
  )
119
119
  elif not settings.telemetry_enabled:
hud/otel/context.py CHANGED
@@ -408,7 +408,7 @@ def _print_trace_url(task_run_id: str) -> None:
408
408
  if not (settings.telemetry_enabled and settings.api_key):
409
409
  return
410
410
 
411
- url = f"https://hud.so/trace/{task_run_id}"
411
+ url = f"https://hud.ai/trace/{task_run_id}"
412
412
  header = "🚀 See your agent live at:"
413
413
 
414
414
  # ANSI color codes
@@ -447,7 +447,7 @@ def _print_trace_complete_url(task_run_id: str, error_occurred: bool = False) ->
447
447
  if not (settings.telemetry_enabled and settings.api_key):
448
448
  return
449
449
 
450
- url = f"https://hud.so/trace/{task_run_id}"
450
+ url = f"https://hud.ai/trace/{task_run_id}"
451
451
 
452
452
  # ANSI color codes
453
453
  GREEN = "\033[92m"