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

Files changed (63) hide show
  1. hud/__main__.py +8 -0
  2. hud/agents/base.py +7 -8
  3. hud/agents/langchain.py +2 -2
  4. hud/agents/tests/test_openai.py +3 -1
  5. hud/cli/__init__.py +114 -52
  6. hud/cli/build.py +121 -71
  7. hud/cli/debug.py +2 -2
  8. hud/cli/{mcp_server.py → dev.py} +101 -38
  9. hud/cli/eval.py +175 -90
  10. hud/cli/init.py +442 -64
  11. hud/cli/list_func.py +72 -71
  12. hud/cli/pull.py +1 -2
  13. hud/cli/push.py +35 -23
  14. hud/cli/remove.py +35 -41
  15. hud/cli/tests/test_analyze.py +2 -1
  16. hud/cli/tests/test_analyze_metadata.py +42 -49
  17. hud/cli/tests/test_build.py +28 -52
  18. hud/cli/tests/test_cursor.py +1 -1
  19. hud/cli/tests/test_debug.py +1 -1
  20. hud/cli/tests/test_list_func.py +75 -64
  21. hud/cli/tests/test_main_module.py +30 -0
  22. hud/cli/tests/test_mcp_server.py +3 -3
  23. hud/cli/tests/test_pull.py +30 -61
  24. hud/cli/tests/test_push.py +70 -89
  25. hud/cli/tests/test_registry.py +36 -38
  26. hud/cli/tests/test_utils.py +1 -1
  27. hud/cli/utils/__init__.py +1 -0
  28. hud/cli/{docker_utils.py → utils/docker.py} +36 -0
  29. hud/cli/{env_utils.py → utils/environment.py} +7 -7
  30. hud/cli/{interactive.py → utils/interactive.py} +91 -19
  31. hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
  32. hud/cli/{registry.py → utils/registry.py} +28 -30
  33. hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
  34. hud/cli/utils/runner.py +134 -0
  35. hud/cli/utils/server.py +250 -0
  36. hud/clients/base.py +1 -1
  37. hud/clients/fastmcp.py +5 -13
  38. hud/clients/mcp_use.py +6 -10
  39. hud/server/server.py +35 -5
  40. hud/shared/exceptions.py +11 -0
  41. hud/shared/tests/test_exceptions.py +22 -0
  42. hud/telemetry/tests/__init__.py +0 -0
  43. hud/telemetry/tests/test_replay.py +40 -0
  44. hud/telemetry/tests/test_trace.py +63 -0
  45. hud/tools/base.py +20 -3
  46. hud/tools/computer/hud.py +15 -6
  47. hud/tools/executors/tests/test_base_executor.py +27 -0
  48. hud/tools/response.py +12 -8
  49. hud/tools/tests/test_response.py +60 -0
  50. hud/tools/tests/test_tools_init.py +49 -0
  51. hud/utils/design.py +19 -8
  52. hud/utils/mcp.py +17 -5
  53. hud/utils/tests/test_mcp.py +112 -0
  54. hud/utils/tests/test_version.py +1 -1
  55. hud/version.py +1 -1
  56. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/METADATA +16 -13
  57. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/RECORD +62 -52
  58. hud/cli/runner.py +0 -160
  59. /hud/cli/{cursor.py → utils/cursor.py} +0 -0
  60. /hud/cli/{utils.py → utils/logging.py} +0 -0
  61. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/licenses/LICENSE +0 -0
hud/cli/build.py CHANGED
@@ -17,18 +17,18 @@ from hud.clients import MCPClient
17
17
  from hud.utils.design import HUDDesign
18
18
  from hud.version import __version__ as hud_version
19
19
 
20
- from .registry import save_to_registry
20
+ from .utils.registry import save_to_registry
21
21
 
22
22
 
23
23
  def parse_version(version_str: str) -> tuple[int, int, int]:
24
24
  """Parse version string like '1.0.0' or '1.0' into tuple of integers."""
25
25
  # Remove 'v' prefix if present
26
- version_str = version_str.lstrip('v')
27
-
26
+ version_str = version_str.lstrip("v")
27
+
28
28
  # Split by dots and pad with zeros if needed
29
- parts = version_str.split('.')
30
- parts.extend(['0'] * (3 - len(parts))) # Ensure we have at least 3 parts
31
-
29
+ parts = version_str.split(".")
30
+ parts.extend(["0"] * (3 - len(parts))) # Ensure we have at least 3 parts
31
+
32
32
  try:
33
33
  return (int(parts[0]), int(parts[1]), int(parts[2]))
34
34
  except (ValueError, IndexError):
@@ -39,7 +39,7 @@ def parse_version(version_str: str) -> tuple[int, int, int]:
39
39
  def increment_version(version_str: str, increment_type: str = "patch") -> str:
40
40
  """Increment version string. increment_type can be 'major', 'minor', or 'patch'."""
41
41
  major, minor, patch = parse_version(version_str)
42
-
42
+
43
43
  if increment_type == "major":
44
44
  return f"{major + 1}.0.0"
45
45
  elif increment_type == "minor":
@@ -52,11 +52,11 @@ def get_existing_version(lock_path: Path) -> str | None:
52
52
  """Get the internal version from existing lock file if it exists."""
53
53
  if not lock_path.exists():
54
54
  return None
55
-
55
+
56
56
  try:
57
57
  with open(lock_path) as f:
58
58
  lock_data = yaml.safe_load(f)
59
-
59
+
60
60
  # Look for internal version in build metadata
61
61
  build_data = lock_data.get("build", {})
62
62
  return build_data.get("version", None)
@@ -81,7 +81,7 @@ def get_docker_image_digest(image: str) -> str | None:
81
81
  if digest_list:
82
82
  # Return full image reference with digest
83
83
  return digest_list[0]
84
- except Exception:
84
+ except Exception: # noqa: S110
85
85
  # Don't print error here, let calling code handle it
86
86
  pass
87
87
  return None
@@ -113,30 +113,60 @@ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str],
113
113
  if not dockerfile_path.exists():
114
114
  return required, optional
115
115
 
116
- # Simple parsing - look for ENV directives
117
- # This is a basic implementation - could be enhanced
116
+ # Parse both ENV and ARG directives
118
117
  content = dockerfile_path.read_text()
118
+ arg_vars = set() # Track ARG variables
119
+
119
120
  for line in content.splitlines():
120
121
  line = line.strip()
121
- if line.startswith("ENV "):
122
- # Basic parsing - this could be more sophisticated
122
+
123
+ # Look for ARG directives (build-time variables)
124
+ if line.startswith("ARG "):
123
125
  parts = line[4:].strip().split("=", 1)
124
- if len(parts) == 2 and not parts[1].strip():
126
+ var_name = parts[0].strip()
127
+ if len(parts) == 1 or not parts[1].strip():
125
128
  # No default value = required
126
- required.append(parts[0])
129
+ arg_vars.add(var_name)
130
+ if var_name not in required:
131
+ required.append(var_name)
132
+
133
+ # Look for ENV directives (runtime variables)
134
+ elif line.startswith("ENV "):
135
+ parts = line[4:].strip().split("=", 1)
136
+ var_name = parts[0].strip()
137
+
138
+ # Check if it references an ARG variable (e.g., ENV MY_VAR=$MY_VAR)
139
+ if len(parts) == 2 and parts[1].strip().startswith("$"):
140
+ ref_var = parts[1].strip()[1:]
141
+ if ref_var in arg_vars and var_name not in required:
142
+ required.append(var_name)
143
+ elif len(parts) == 2 and not parts[1].strip():
144
+ # No default value = required
145
+ if var_name not in required:
146
+ required.append(var_name)
127
147
  elif len(parts) == 1:
128
148
  # No equals sign = required
129
- required.append(parts[0])
149
+ if var_name not in required:
150
+ required.append(var_name)
130
151
 
131
152
  return required, optional
132
153
 
133
154
 
134
- async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str, Any]:
155
+ async def analyze_mcp_environment(
156
+ image: str, verbose: bool = False, env_vars: dict[str, str] | None = None
157
+ ) -> dict[str, Any]:
135
158
  """Analyze an MCP environment to extract metadata."""
136
159
  design = HUDDesign()
160
+ env_vars = env_vars or {}
137
161
 
138
162
  # Build Docker command to run the image
139
- docker_cmd = ["docker", "run", "--rm", "-i", image]
163
+ docker_cmd = ["docker", "run", "--rm", "-i"]
164
+
165
+ # Add environment variables
166
+ for key, value in env_vars.items():
167
+ docker_cmd.extend(["-e", f"{key}={value}"])
168
+
169
+ docker_cmd.append(image)
140
170
 
141
171
  # Create MCP config
142
172
  config = {
@@ -209,10 +239,15 @@ async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str
209
239
 
210
240
 
211
241
  def build_docker_image(
212
- directory: Path, tag: str, no_cache: bool = False, verbose: bool = False
242
+ directory: Path,
243
+ tag: str,
244
+ no_cache: bool = False,
245
+ verbose: bool = False,
246
+ build_args: dict[str, str] | None = None,
213
247
  ) -> bool:
214
248
  """Build a Docker image from a directory."""
215
249
  design = HUDDesign()
250
+ build_args = build_args or {}
216
251
 
217
252
  # Check if Dockerfile exists
218
253
  dockerfile = directory / "Dockerfile"
@@ -224,39 +259,35 @@ def build_docker_image(
224
259
  cmd = ["docker", "build", "-t", tag]
225
260
  if no_cache:
226
261
  cmd.append("--no-cache")
262
+
263
+ # Add build args
264
+ for key, value in build_args.items():
265
+ cmd.extend(["--build-arg", f"{key}={value}"])
266
+
227
267
  cmd.append(str(directory))
228
268
 
229
269
  # Always show build output
230
270
  design.info(f"Running: {' '.join(cmd)}")
231
271
 
232
272
  try:
233
- # Run with real-time output, handling encoding issues on Windows
234
- process = subprocess.Popen( # noqa: S603
235
- cmd,
236
- stdout=subprocess.PIPE,
237
- stderr=subprocess.STDOUT,
238
- text=True,
239
- encoding="utf-8",
240
- errors="replace", # Replace invalid chars instead of failing
241
- )
242
-
243
- # Stream output
244
- for line in process.stdout or []:
245
- design.info(line.rstrip())
246
-
247
- process.wait()
248
-
249
- return process.returncode == 0
273
+ # Use Docker's native output formatting - no capture, let Docker handle display
274
+ result = subprocess.run(cmd, check=False) # noqa: S603
275
+ return result.returncode == 0
250
276
  except Exception as e:
251
277
  design.error(f"Build error: {e}")
252
278
  return False
253
279
 
254
280
 
255
281
  def build_environment(
256
- directory: str = ".", tag: str | None = None, no_cache: bool = False, verbose: bool = False
282
+ directory: str = ".",
283
+ tag: str | None = None,
284
+ no_cache: bool = False,
285
+ verbose: bool = False,
286
+ env_vars: dict[str, str] | None = None,
257
287
  ) -> None:
258
288
  """Build a HUD environment and generate lock file."""
259
289
  design = HUDDesign()
290
+ env_vars = env_vars or {}
260
291
  design.header("HUD Environment Build")
261
292
 
262
293
  # Resolve directory
@@ -292,7 +323,7 @@ def build_environment(
292
323
 
293
324
  design.progress_message(f"Building Docker image: {temp_tag}")
294
325
 
295
- # Build the image
326
+ # Build the image (env vars are for runtime, not build time)
296
327
  if not build_docker_image(env_dir, temp_tag, no_cache, verbose):
297
328
  design.error("Docker build failed")
298
329
  raise typer.Exit(1)
@@ -305,7 +336,7 @@ def build_environment(
305
336
  loop = asyncio.new_event_loop()
306
337
  asyncio.set_event_loop(loop)
307
338
  try:
308
- analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose))
339
+ analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose, env_vars))
309
340
  finally:
310
341
  loop.close()
311
342
 
@@ -316,9 +347,9 @@ def build_environment(
316
347
 
317
348
  # Provide helpful debugging tips
318
349
  design.section_title("Debugging Tips")
319
- design.info("1. Test your server directly:")
320
- design.command_example(f"docker run --rm -it {temp_tag}")
321
- design.dim_info(" Expected output", "MCP initialization messages")
350
+ design.info("1. Debug your environment build:")
351
+ design.command_example("hud debug . --build")
352
+ design.dim_info(" This will", "test MCP server connection and show detailed logs")
322
353
  design.info("")
323
354
  design.info("2. Check for common issues:")
324
355
  design.info(" - Server crashes on startup")
@@ -336,10 +367,28 @@ def build_environment(
336
367
  dockerfile_path = env_dir / "Dockerfile"
337
368
  required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
338
369
 
370
+ # Merge user-provided env vars with detected ones
371
+ provided_env_vars = {}
372
+ missing_required = []
373
+ if env_vars:
374
+ provided_env_vars = env_vars.copy()
375
+ # Track which required vars are still missing
376
+ missing_required = [e for e in required_env if e not in env_vars]
377
+
378
+ # Show what env vars were provided
379
+ design.success(f"Using provided environment variables: {', '.join(env_vars.keys())}")
380
+ else:
381
+ missing_required = required_env[:]
382
+
383
+ # Warn about missing required variables
384
+ if missing_required:
385
+ design.warning(f"Missing required environment variables: {', '.join(missing_required)}")
386
+ design.info("These can be added to the lock file after build or provided with -e flags")
387
+
339
388
  # Check for existing version and increment
340
389
  lock_path = env_dir / "hud.lock.yaml"
341
390
  existing_version = get_existing_version(lock_path)
342
-
391
+
343
392
  if existing_version:
344
393
  # Increment existing version
345
394
  new_version = increment_version(existing_version)
@@ -365,11 +414,20 @@ def build_environment(
365
414
  },
366
415
  }
367
416
 
368
- # Only add environment variables if they exist
369
- if required_env or optional_env:
417
+ # Add environment variables section if any exist
418
+ if missing_required or optional_env or provided_env_vars:
370
419
  lock_content["environment"]["variables"] = {}
371
- if required_env:
372
- lock_content["environment"]["variables"]["required"] = required_env
420
+
421
+ # Add note about editing environment variables
422
+ lock_content["environment"]["variables"]["_note"] = (
423
+ "You can edit this section to add or modify environment variables. "
424
+ "Provided variables will be used when running the environment."
425
+ )
426
+
427
+ if provided_env_vars:
428
+ lock_content["environment"]["variables"]["provided"] = provided_env_vars
429
+ if missing_required:
430
+ lock_content["environment"]["variables"]["required"] = missing_required
373
431
  if optional_env:
374
432
  lock_content["environment"]["variables"]["optional"] = optional_env
375
433
 
@@ -399,7 +457,7 @@ def build_environment(
399
457
  # Also tag with version
400
458
  base_name = tag.split(":")[0] if tag and ":" in tag else tag
401
459
  version_tag = f"{base_name}:{new_version}"
402
-
460
+
403
461
  label_cmd = [
404
462
  "docker",
405
463
  "build",
@@ -411,30 +469,21 @@ def build_environment(
411
469
  tag,
412
470
  "-t",
413
471
  version_tag,
414
- str(env_dir),
415
472
  ]
416
473
 
417
- # Run rebuild with proper encoding
418
- process = subprocess.Popen( # noqa: S603
419
- label_cmd,
420
- stdout=subprocess.PIPE,
421
- stderr=subprocess.STDOUT,
422
- text=True,
423
- encoding="utf-8",
424
- errors="replace",
425
- )
426
-
427
- # Stream output if verbose
474
+ label_cmd.append(str(env_dir))
475
+
476
+ # Run rebuild using Docker's native output formatting
428
477
  if verbose:
429
- for line in process.stdout or []:
430
- design.info(line.rstrip())
478
+ # Show Docker's native output when verbose
479
+ result = subprocess.run(label_cmd, check=False) # noqa: S603
431
480
  else:
432
- # Just consume output to avoid blocking
433
- process.stdout.read() # type: ignore
434
-
435
- process.wait()
481
+ # Hide output when not verbose
482
+ result = subprocess.run( # noqa: S603
483
+ label_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False
484
+ )
436
485
 
437
- if process.returncode != 0:
486
+ if result.returncode != 0:
438
487
  design.error("Failed to rebuild with label")
439
488
  raise typer.Exit(1)
440
489
 
@@ -475,8 +524,8 @@ def build_environment(
475
524
  design.status_item("Also tagged", tag)
476
525
  design.status_item("Version", new_version)
477
526
  design.status_item("Lock file", "hud.lock.yaml")
478
- design.status_item("Tools found", str(analysis['toolCount']))
479
-
527
+ design.status_item("Tools found", str(analysis["toolCount"]))
528
+
480
529
  # Show the digest info separately if we have it
481
530
  if image_id:
482
531
  design.dim_info("\nImage digest", image_id)
@@ -500,6 +549,7 @@ def build_command(
500
549
  ),
501
550
  no_cache: bool = typer.Option(False, "--no-cache", help="Build without Docker cache"),
502
551
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
552
+ env_vars: dict[str, str] | None = None,
503
553
  ) -> None:
504
554
  """Build a HUD environment and generate lock file."""
505
- build_environment(directory, tag, no_cache, verbose)
555
+ build_environment(directory, tag, no_cache, verbose, env_vars)
hud/cli/debug.py CHANGED
@@ -14,7 +14,7 @@ from rich.console import Console
14
14
  from hud.clients import MCPClient
15
15
  from hud.utils.design import HUDDesign
16
16
 
17
- from .utils import CaptureLogger, Colors, analyze_error_for_hints
17
+ from .utils.logging import CaptureLogger, Colors, analyze_error_for_hints
18
18
 
19
19
  console = Console()
20
20
 
@@ -167,7 +167,7 @@ async def debug_mcp_stdio(command: list[str], logger: CaptureLogger, max_phase:
167
167
  break
168
168
  except Exception as e:
169
169
  logger.error(f"Failed to parse MCP response: {e}")
170
- logger.error(f"Raw output that caused the error: {repr(line)}")
170
+ logger.error(f"Raw output that caused the error: {line!r}")
171
171
  logger.hint("This usually means non-JSON output is being sent to STDOUT")
172
172
  logger.hint("Common causes:")
173
173
  logger.hint(" - Print statements in your server code")