hud-python 0.4.45__py3-none-any.whl → 0.5.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.
Files changed (282) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +70 -5
  3. hud/agents/base.py +238 -500
  4. hud/agents/claude.py +236 -247
  5. hud/agents/gateway.py +42 -0
  6. hud/agents/gemini.py +264 -0
  7. hud/agents/gemini_cua.py +324 -0
  8. hud/agents/grounded_openai.py +98 -100
  9. hud/agents/misc/integration_test_agent.py +51 -20
  10. hud/agents/misc/response_agent.py +48 -36
  11. hud/agents/openai.py +282 -296
  12. hud/agents/{openai_chat_generic.py → openai_chat.py} +63 -33
  13. hud/agents/operator.py +199 -0
  14. hud/agents/resolver.py +70 -0
  15. hud/agents/tests/conftest.py +133 -0
  16. hud/agents/tests/test_base.py +300 -622
  17. hud/agents/tests/test_base_runtime.py +233 -0
  18. hud/agents/tests/test_claude.py +381 -214
  19. hud/agents/tests/test_client.py +9 -10
  20. hud/agents/tests/test_gemini.py +369 -0
  21. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  22. hud/agents/tests/test_openai.py +377 -140
  23. hud/agents/tests/test_operator.py +362 -0
  24. hud/agents/tests/test_resolver.py +192 -0
  25. hud/agents/tests/test_run_eval.py +179 -0
  26. hud/agents/types.py +148 -0
  27. hud/cli/__init__.py +493 -546
  28. hud/cli/analyze.py +43 -5
  29. hud/cli/build.py +699 -113
  30. hud/cli/debug.py +8 -5
  31. hud/cli/dev.py +889 -732
  32. hud/cli/eval.py +793 -667
  33. hud/cli/flows/dev.py +167 -0
  34. hud/cli/flows/init.py +191 -0
  35. hud/cli/flows/tasks.py +153 -56
  36. hud/cli/flows/templates.py +151 -0
  37. hud/cli/flows/tests/__init__.py +1 -0
  38. hud/cli/flows/tests/test_dev.py +126 -0
  39. hud/cli/init.py +60 -58
  40. hud/cli/pull.py +1 -1
  41. hud/cli/push.py +38 -13
  42. hud/cli/rft.py +311 -0
  43. hud/cli/rft_status.py +145 -0
  44. hud/cli/tests/test_analyze.py +5 -5
  45. hud/cli/tests/test_analyze_metadata.py +3 -2
  46. hud/cli/tests/test_analyze_module.py +120 -0
  47. hud/cli/tests/test_build.py +110 -8
  48. hud/cli/tests/test_build_failure.py +41 -0
  49. hud/cli/tests/test_build_module.py +50 -0
  50. hud/cli/tests/test_cli_init.py +6 -1
  51. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  52. hud/cli/tests/test_cli_root.py +140 -0
  53. hud/cli/tests/test_convert.py +361 -0
  54. hud/cli/tests/test_debug.py +12 -10
  55. hud/cli/tests/test_dev.py +197 -0
  56. hud/cli/tests/test_eval.py +251 -0
  57. hud/cli/tests/test_eval_bedrock.py +51 -0
  58. hud/cli/tests/test_init.py +124 -0
  59. hud/cli/tests/test_main_module.py +11 -5
  60. hud/cli/tests/test_mcp_server.py +12 -100
  61. hud/cli/tests/test_push.py +1 -1
  62. hud/cli/tests/test_push_happy.py +74 -0
  63. hud/cli/tests/test_push_wrapper.py +23 -0
  64. hud/cli/tests/test_registry.py +1 -1
  65. hud/cli/tests/test_utils.py +1 -1
  66. hud/cli/{rl → utils}/celebrate.py +14 -12
  67. hud/cli/utils/config.py +18 -1
  68. hud/cli/utils/docker.py +130 -4
  69. hud/cli/utils/env_check.py +9 -9
  70. hud/cli/utils/git.py +136 -0
  71. hud/cli/utils/interactive.py +39 -5
  72. hud/cli/utils/metadata.py +70 -1
  73. hud/cli/utils/runner.py +1 -1
  74. hud/cli/utils/server.py +2 -2
  75. hud/cli/utils/source_hash.py +3 -3
  76. hud/cli/utils/tasks.py +4 -1
  77. hud/cli/utils/tests/__init__.py +0 -0
  78. hud/cli/utils/tests/test_config.py +58 -0
  79. hud/cli/utils/tests/test_docker.py +93 -0
  80. hud/cli/utils/tests/test_docker_hints.py +71 -0
  81. hud/cli/utils/tests/test_env_check.py +74 -0
  82. hud/cli/utils/tests/test_environment.py +42 -0
  83. hud/cli/utils/tests/test_git.py +142 -0
  84. hud/cli/utils/tests/test_interactive_module.py +60 -0
  85. hud/cli/utils/tests/test_local_runner.py +50 -0
  86. hud/cli/utils/tests/test_logging_utils.py +23 -0
  87. hud/cli/utils/tests/test_metadata.py +49 -0
  88. hud/cli/utils/tests/test_package_runner.py +35 -0
  89. hud/cli/utils/tests/test_registry_utils.py +49 -0
  90. hud/cli/utils/tests/test_remote_runner.py +25 -0
  91. hud/cli/utils/tests/test_runner_modules.py +52 -0
  92. hud/cli/utils/tests/test_source_hash.py +36 -0
  93. hud/cli/utils/tests/test_tasks.py +80 -0
  94. hud/cli/utils/version_check.py +258 -0
  95. hud/cli/{rl → utils}/viewer.py +2 -2
  96. hud/clients/README.md +12 -11
  97. hud/clients/__init__.py +4 -3
  98. hud/clients/base.py +166 -26
  99. hud/clients/environment.py +51 -0
  100. hud/clients/fastmcp.py +13 -6
  101. hud/clients/mcp_use.py +45 -15
  102. hud/clients/tests/test_analyze_scenarios.py +206 -0
  103. hud/clients/tests/test_protocol.py +9 -3
  104. hud/datasets/__init__.py +23 -20
  105. hud/datasets/loader.py +326 -0
  106. hud/datasets/runner.py +198 -105
  107. hud/datasets/tests/__init__.py +0 -0
  108. hud/datasets/tests/test_loader.py +221 -0
  109. hud/datasets/tests/test_utils.py +315 -0
  110. hud/datasets/utils.py +270 -90
  111. hud/environment/__init__.py +52 -0
  112. hud/environment/connection.py +258 -0
  113. hud/environment/connectors/__init__.py +33 -0
  114. hud/environment/connectors/base.py +68 -0
  115. hud/environment/connectors/local.py +177 -0
  116. hud/environment/connectors/mcp_config.py +137 -0
  117. hud/environment/connectors/openai.py +101 -0
  118. hud/environment/connectors/remote.py +172 -0
  119. hud/environment/environment.py +835 -0
  120. hud/environment/integrations/__init__.py +45 -0
  121. hud/environment/integrations/adk.py +67 -0
  122. hud/environment/integrations/anthropic.py +196 -0
  123. hud/environment/integrations/gemini.py +92 -0
  124. hud/environment/integrations/langchain.py +82 -0
  125. hud/environment/integrations/llamaindex.py +68 -0
  126. hud/environment/integrations/openai.py +238 -0
  127. hud/environment/mock.py +306 -0
  128. hud/environment/router.py +263 -0
  129. hud/environment/scenarios.py +620 -0
  130. hud/environment/tests/__init__.py +1 -0
  131. hud/environment/tests/test_connection.py +317 -0
  132. hud/environment/tests/test_connectors.py +205 -0
  133. hud/environment/tests/test_environment.py +593 -0
  134. hud/environment/tests/test_integrations.py +257 -0
  135. hud/environment/tests/test_local_connectors.py +242 -0
  136. hud/environment/tests/test_scenarios.py +1086 -0
  137. hud/environment/tests/test_tools.py +208 -0
  138. hud/environment/types.py +23 -0
  139. hud/environment/utils/__init__.py +35 -0
  140. hud/environment/utils/formats.py +215 -0
  141. hud/environment/utils/schema.py +171 -0
  142. hud/environment/utils/tool_wrappers.py +113 -0
  143. hud/eval/__init__.py +67 -0
  144. hud/eval/context.py +727 -0
  145. hud/eval/display.py +299 -0
  146. hud/eval/instrument.py +187 -0
  147. hud/eval/manager.py +533 -0
  148. hud/eval/parallel.py +268 -0
  149. hud/eval/task.py +372 -0
  150. hud/eval/tests/__init__.py +1 -0
  151. hud/eval/tests/test_context.py +178 -0
  152. hud/eval/tests/test_eval.py +210 -0
  153. hud/eval/tests/test_manager.py +152 -0
  154. hud/eval/tests/test_parallel.py +168 -0
  155. hud/eval/tests/test_task.py +291 -0
  156. hud/eval/types.py +65 -0
  157. hud/eval/utils.py +194 -0
  158. hud/patches/__init__.py +19 -0
  159. hud/patches/mcp_patches.py +308 -0
  160. hud/patches/warnings.py +54 -0
  161. hud/samples/browser.py +4 -4
  162. hud/server/__init__.py +2 -1
  163. hud/server/low_level.py +2 -1
  164. hud/server/router.py +164 -0
  165. hud/server/server.py +567 -80
  166. hud/server/tests/test_mcp_server_integration.py +11 -11
  167. hud/server/tests/test_mcp_server_more.py +1 -1
  168. hud/server/tests/test_server_extra.py +2 -0
  169. hud/settings.py +45 -3
  170. hud/shared/exceptions.py +36 -10
  171. hud/shared/hints.py +26 -1
  172. hud/shared/requests.py +15 -3
  173. hud/shared/tests/test_exceptions.py +40 -31
  174. hud/shared/tests/test_hints.py +167 -0
  175. hud/telemetry/__init__.py +20 -19
  176. hud/telemetry/exporter.py +201 -0
  177. hud/telemetry/instrument.py +165 -253
  178. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  179. hud/telemetry/tests/test_exporter.py +258 -0
  180. hud/telemetry/tests/test_instrument.py +401 -0
  181. hud/tools/__init__.py +18 -2
  182. hud/tools/agent.py +223 -0
  183. hud/tools/apply_patch.py +639 -0
  184. hud/tools/base.py +54 -4
  185. hud/tools/bash.py +2 -2
  186. hud/tools/computer/__init__.py +36 -3
  187. hud/tools/computer/anthropic.py +2 -2
  188. hud/tools/computer/gemini.py +385 -0
  189. hud/tools/computer/hud.py +23 -6
  190. hud/tools/computer/openai.py +20 -21
  191. hud/tools/computer/qwen.py +434 -0
  192. hud/tools/computer/settings.py +37 -0
  193. hud/tools/edit.py +3 -7
  194. hud/tools/executors/base.py +4 -2
  195. hud/tools/executors/pyautogui.py +1 -1
  196. hud/tools/grounding/grounded_tool.py +13 -18
  197. hud/tools/grounding/grounder.py +10 -31
  198. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  199. hud/tools/jupyter.py +330 -0
  200. hud/tools/playwright.py +18 -3
  201. hud/tools/shell.py +308 -0
  202. hud/tools/tests/test_agent_tool.py +355 -0
  203. hud/tools/tests/test_apply_patch.py +718 -0
  204. hud/tools/tests/test_computer.py +4 -9
  205. hud/tools/tests/test_computer_actions.py +24 -2
  206. hud/tools/tests/test_jupyter_tool.py +181 -0
  207. hud/tools/tests/test_shell.py +596 -0
  208. hud/tools/tests/test_submit.py +85 -0
  209. hud/tools/tests/test_types.py +193 -0
  210. hud/tools/types.py +21 -1
  211. hud/types.py +194 -56
  212. hud/utils/__init__.py +2 -0
  213. hud/utils/env.py +67 -0
  214. hud/utils/hud_console.py +89 -18
  215. hud/utils/mcp.py +15 -58
  216. hud/utils/strict_schema.py +162 -0
  217. hud/utils/tests/test_init.py +1 -2
  218. hud/utils/tests/test_mcp.py +1 -28
  219. hud/utils/tests/test_pretty_errors.py +186 -0
  220. hud/utils/tests/test_tool_shorthand.py +154 -0
  221. hud/utils/tests/test_version.py +1 -1
  222. hud/utils/types.py +20 -0
  223. hud/version.py +1 -1
  224. hud_python-0.5.13.dist-info/METADATA +264 -0
  225. hud_python-0.5.13.dist-info/RECORD +305 -0
  226. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/WHEEL +1 -1
  227. hud/agents/langchain.py +0 -261
  228. hud/agents/lite_llm.py +0 -72
  229. hud/cli/rl/__init__.py +0 -180
  230. hud/cli/rl/config.py +0 -101
  231. hud/cli/rl/display.py +0 -133
  232. hud/cli/rl/gpu.py +0 -63
  233. hud/cli/rl/gpu_utils.py +0 -321
  234. hud/cli/rl/local_runner.py +0 -595
  235. hud/cli/rl/presets.py +0 -96
  236. hud/cli/rl/remote_runner.py +0 -463
  237. hud/cli/rl/rl_api.py +0 -150
  238. hud/cli/rl/vllm.py +0 -177
  239. hud/cli/rl/wait_utils.py +0 -89
  240. hud/datasets/parallel.py +0 -687
  241. hud/misc/__init__.py +0 -1
  242. hud/misc/claude_plays_pokemon.py +0 -292
  243. hud/otel/__init__.py +0 -35
  244. hud/otel/collector.py +0 -142
  245. hud/otel/config.py +0 -181
  246. hud/otel/context.py +0 -570
  247. hud/otel/exporters.py +0 -369
  248. hud/otel/instrumentation.py +0 -135
  249. hud/otel/processors.py +0 -121
  250. hud/otel/tests/__init__.py +0 -1
  251. hud/otel/tests/test_processors.py +0 -197
  252. hud/rl/README.md +0 -30
  253. hud/rl/__init__.py +0 -1
  254. hud/rl/actor.py +0 -176
  255. hud/rl/buffer.py +0 -405
  256. hud/rl/chat_template.jinja +0 -101
  257. hud/rl/config.py +0 -192
  258. hud/rl/distributed.py +0 -132
  259. hud/rl/learner.py +0 -637
  260. hud/rl/tests/__init__.py +0 -1
  261. hud/rl/tests/test_learner.py +0 -186
  262. hud/rl/train.py +0 -382
  263. hud/rl/types.py +0 -101
  264. hud/rl/utils/start_vllm_server.sh +0 -30
  265. hud/rl/utils.py +0 -524
  266. hud/rl/vllm_adapter.py +0 -143
  267. hud/telemetry/job.py +0 -352
  268. hud/telemetry/replay.py +0 -74
  269. hud/telemetry/tests/test_replay.py +0 -40
  270. hud/telemetry/tests/test_trace.py +0 -63
  271. hud/telemetry/trace.py +0 -158
  272. hud/utils/agent_factories.py +0 -86
  273. hud/utils/async_utils.py +0 -65
  274. hud/utils/group_eval.py +0 -223
  275. hud/utils/progress.py +0 -149
  276. hud/utils/tasks.py +0 -127
  277. hud/utils/tests/test_async_utils.py +0 -173
  278. hud/utils/tests/test_progress.py +0 -261
  279. hud_python-0.4.45.dist-info/METADATA +0 -552
  280. hud_python-0.4.45.dist-info/RECORD +0 -228
  281. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
  282. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/licenses/LICENSE +0 -0
hud/cli/rft_status.py ADDED
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import httpx
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from hud.cli.utils.viewer import show_json_interactive
10
+ from hud.settings import settings
11
+ from hud.utils.hud_console import HUDConsole
12
+
13
+ logger = logging.getLogger(__name__)
14
+ console = Console()
15
+ hud_console = HUDConsole()
16
+
17
+
18
+ def rft_status_command(
19
+ model_id: str,
20
+ verbose: bool = False,
21
+ ) -> None:
22
+ """
23
+ Check the status of an RFT training job.
24
+ """
25
+ # hud_console.header("RFT Job Status")
26
+
27
+ # Preflight check: API key
28
+ if not settings.api_key:
29
+ hud_console.error("HUD_API_KEY not found in environment.")
30
+ hud_console.info("Run 'hud set HUD_API_KEY=...' or export it.")
31
+ raise typer.Exit(1)
32
+
33
+ # Prepare request
34
+ base_url = settings.hud_rl_url
35
+ url = f"{base_url}/training/jobs/{model_id}/raw-status"
36
+
37
+ headers = {"Authorization": f"Bearer {settings.api_key}"}
38
+
39
+ hud_console.info(f"Fetching status for model: {model_id}")
40
+
41
+ try:
42
+ with httpx.Client(timeout=30.0) as client:
43
+ resp = client.get(url, headers=headers)
44
+
45
+ if resp.status_code >= 400:
46
+ try:
47
+ detail = resp.json()
48
+ except Exception as e:
49
+ detail = f"{resp.text} - {e}"
50
+ hud_console.error(f"Request failed ({resp.status_code}): {detail}")
51
+ raise typer.Exit(1)
52
+
53
+ data = resp.json()
54
+
55
+ # Display status information
56
+ status = data.get("status", "Unknown")
57
+
58
+ # Show status with appropriate styling
59
+ if status.lower() in ["succeeded", "completed"]:
60
+ hud_console.success(f"Job Status: {status}")
61
+ elif status.lower() in ["failed", "error", "cancelled"]:
62
+ hud_console.error(f"Job Status: {status}")
63
+ elif status.lower() in [
64
+ "running",
65
+ "in_progress",
66
+ "processing",
67
+ "validating_files",
68
+ "queued",
69
+ ]:
70
+ hud_console.info(f"Job Status: {status} 🔄")
71
+ else:
72
+ hud_console.info(f"Job Status: {status}")
73
+
74
+ # Most important: Show fine-tuned model if available
75
+ if data.get("fine_tuned_model"):
76
+ hud_console.success(f"Fine-tuned Model: {data['fine_tuned_model']}")
77
+ console.print("\n[dim]You can now use this model in your applications![/dim]")
78
+
79
+ # Display full response in verbose mode or interactive viewer
80
+ if verbose:
81
+ hud_console.section_title("Full Status Details")
82
+ show_json_interactive(data, title="RFT Job Status", initial_expanded=True)
83
+ else:
84
+ # Show key information
85
+ if "model" in data:
86
+ hud_console.info(f"Base Model: {data['model']}")
87
+
88
+ if "created_at" in data:
89
+ # Convert timestamp to readable format if it's a unix timestamp
90
+ created = data["created_at"]
91
+ if isinstance(created, int) and created > 1000000000:
92
+ from datetime import datetime
93
+
94
+ created_str = datetime.fromtimestamp(created).strftime("%Y-%m-%d %H:%M:%S")
95
+ hud_console.info(f"Created: {created_str}")
96
+ else:
97
+ hud_console.info(f"Created: {created}")
98
+
99
+ if data.get("finished_at"):
100
+ finished = data["finished_at"]
101
+ if isinstance(finished, int) and finished > 1000000000:
102
+ from datetime import datetime
103
+
104
+ finished_str = datetime.fromtimestamp(finished).strftime(
105
+ "%Y-%m-%d %H:%M:%S"
106
+ )
107
+ hud_console.info(f"Finished: {finished_str}")
108
+ else:
109
+ hud_console.info(f"Finished: {finished}")
110
+
111
+ if data.get("trained_tokens"):
112
+ hud_console.info(f"Trained Tokens: {data['trained_tokens']:,}")
113
+
114
+ if (
115
+ "estimated_finish" in data
116
+ and data["estimated_finish"]
117
+ and data["estimated_finish"] > 0
118
+ ):
119
+ from datetime import datetime
120
+
121
+ est_str = datetime.fromtimestamp(data["estimated_finish"]).strftime(
122
+ "%Y-%m-%d %H:%M:%S"
123
+ )
124
+ hud_console.info(f"Estimated Finish: {est_str}")
125
+
126
+ # Only show error if it's actually an error (not empty/null)
127
+ if data.get("error"):
128
+ error = data["error"]
129
+ # Check if it's a real error
130
+ if isinstance(error, dict):
131
+ # Check if any field has actual content
132
+ has_content = any(error.get(k) for k in ["code", "message", "param"])
133
+ if has_content:
134
+ error_msg = error.get("message") or str(error)
135
+ hud_console.error(f"Error: {error_msg}")
136
+ elif isinstance(error, str) and error.strip():
137
+ hud_console.error(f"Error: {error}")
138
+
139
+ # Suggest verbose mode for more details
140
+ console.print("\n[dim]Use --verbose to see full status details[/dim]")
141
+
142
+ except httpx.RequestError as e:
143
+ hud_console.error(f"Connection error: {e}")
144
+ hud_console.info("Is the RL service running?")
145
+ raise typer.Exit(1) from e
@@ -50,7 +50,7 @@ class TestAnalyzeEnvironment:
50
50
  }
51
51
 
52
52
  with (
53
- patch("hud.cli.analyze.MCPClient") as MockClient,
53
+ patch("hud.clients.fastmcp.FastMCPHUDClient") as MockClient,
54
54
  patch("hud.cli.analyze.console"),
55
55
  patch("hud.cli.analyze.display_interactive") as mock_interactive,
56
56
  ):
@@ -80,7 +80,7 @@ class TestAnalyzeEnvironment:
80
80
  async def test_analyze_environment_failure(self) -> None:
81
81
  """Test handling analysis failure."""
82
82
  with (
83
- patch("hud.cli.analyze.MCPClient") as MockClient,
83
+ patch("hud.clients.fastmcp.FastMCPHUDClient") as MockClient,
84
84
  patch("hud.cli.analyze.console") as mock_console,
85
85
  patch("platform.system", return_value="Windows"),
86
86
  ):
@@ -119,7 +119,7 @@ class TestAnalyzeEnvironment:
119
119
 
120
120
  for output_format in ["json", "markdown", "interactive"]:
121
121
  with (
122
- patch("hud.cli.analyze.MCPClient") as MockClient,
122
+ patch("hud.clients.fastmcp.FastMCPHUDClient") as MockClient,
123
123
  patch("hud.cli.analyze.console") as mock_console,
124
124
  patch("hud.cli.analyze.display_interactive") as mock_interactive,
125
125
  patch("hud.cli.analyze.display_markdown") as mock_markdown,
@@ -163,7 +163,7 @@ class TestAnalyzeWithConfig:
163
163
  }
164
164
 
165
165
  with (
166
- patch("hud.cli.analyze.MCPClient") as MockClient,
166
+ patch("hud.clients.fastmcp.FastMCPHUDClient") as MockClient,
167
167
  patch("hud.cli.analyze.console"),
168
168
  patch("hud.cli.analyze.display_interactive") as mock_interactive,
169
169
  ):
@@ -190,7 +190,7 @@ class TestAnalyzeWithConfig:
190
190
  mock_config = {"server": {"command": "test"}}
191
191
 
192
192
  with (
193
- patch("hud.cli.analyze.MCPClient") as MockClient,
193
+ patch("hud.clients.fastmcp.FastMCPHUDClient") as MockClient,
194
194
  patch("hud.cli.analyze.console"),
195
195
  ):
196
196
  # Setup mock client that fails
@@ -214,6 +214,7 @@ class TestAnalyzeFromMetadata:
214
214
 
215
215
  @mock.patch("hud.cli.utils.metadata.check_local_cache")
216
216
  @mock.patch("hud.cli.utils.metadata.fetch_lock_from_registry")
217
+ @mock.patch("hud.cli.utils.metadata.hud_console")
217
218
  @mock.patch("hud.cli.utils.metadata.console")
218
219
  async def test_analyze_not_found(self, mock_console, mock_hud_console, mock_fetch, mock_check):
219
220
  """Test when environment not found anywhere."""
@@ -222,9 +223,9 @@ class TestAnalyzeFromMetadata:
222
223
 
223
224
  await analyze_from_metadata("test/notfound:latest", "json", verbose=False)
224
225
 
225
- # Should show error
226
+ # Should show error via hud_console
226
227
  mock_hud_console.error.assert_called_with("Environment metadata not found")
227
- # Should print suggestions
228
+ # Should print suggestions via console
228
229
  mock_console.print.assert_called()
229
230
 
230
231
  @mock.patch("hud.cli.utils.metadata.check_local_cache")
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.analyze import (
9
+ analyze_environment,
10
+ analyze_environment_from_config,
11
+ analyze_environment_from_mcp_config,
12
+ display_interactive,
13
+ display_markdown,
14
+ parse_docker_command,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ # Mark entire module as asyncio to ensure async tests run with pytest-asyncio
22
+ pytestmark = pytest.mark.asyncio
23
+
24
+
25
+ def test_parse_docker_command():
26
+ cmd = ["docker", "run", "--rm", "-i", "img"]
27
+ cfg = parse_docker_command(cmd)
28
+ assert cfg == {"local": {"command": "docker", "args": ["run", "--rm", "-i", "img"]}}
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ @patch("hud.clients.fastmcp.FastMCPHUDClient")
33
+ @patch("hud.cli.analyze.console")
34
+ async def test_analyze_environment_success_json(mock_console, MockClient):
35
+ client = AsyncMock()
36
+ client.initialize.return_value = None
37
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
38
+ client.shutdown.return_value = None
39
+ MockClient.return_value = client
40
+
41
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=False)
42
+ assert client.initialize.awaited
43
+ assert client.analyze_environment.awaited
44
+ assert client.shutdown.awaited
45
+ assert mock_console.print_json.called
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ @patch("hud.clients.fastmcp.FastMCPHUDClient")
50
+ @patch("hud.cli.analyze.console")
51
+ async def test_analyze_environment_failure(mock_console, MockClient):
52
+ client = AsyncMock()
53
+ client.initialize.side_effect = RuntimeError("boom")
54
+ client.shutdown.return_value = None
55
+ MockClient.return_value = client
56
+
57
+ # Should swallow exception and return without raising
58
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=True)
59
+ assert client.shutdown.awaited
60
+ assert mock_console.print_json.called is False
61
+
62
+
63
+ def test_display_interactive_metadata_only(monkeypatch):
64
+ import hud.cli.analyze as mod
65
+
66
+ monkeypatch.setattr(mod, "console", MagicMock(), raising=False)
67
+ monkeypatch.setattr(mod, "hud_console", MagicMock(), raising=False)
68
+
69
+ analysis = {
70
+ "image": "img:latest",
71
+ "status": "cached",
72
+ "tool_count": 2,
73
+ "tools": [
74
+ {"name": "t1", "description": "d1", "inputSchema": {"type": "object"}},
75
+ {"name": "t2", "description": "d2"},
76
+ ],
77
+ "resources": [],
78
+ }
79
+ display_interactive(analysis)
80
+
81
+
82
+ def test_display_markdown_both_paths(capsys):
83
+ # metadata-only
84
+ md_only = {"image": "img:latest", "tool_count": 0, "tools": [], "resources": []}
85
+ display_markdown(md_only)
86
+
87
+ # live metadata
88
+ live = {"metadata": {"servers": ["s1"], "initialized": True}, "tools": [], "resources": []}
89
+ display_markdown(live)
90
+
91
+ # Check that output was generated
92
+ captured = capsys.readouterr()
93
+ assert "MCP Environment Analysis" in captured.out
94
+
95
+
96
+ @patch("hud.clients.fastmcp.FastMCPHUDClient")
97
+ async def test_analyze_environment_from_config(MockClient, tmp_path: Path):
98
+ client = AsyncMock()
99
+ client.initialize.return_value = None
100
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
101
+ client.shutdown.return_value = None
102
+ MockClient.return_value = client
103
+
104
+ cfg = tmp_path / "mcp.json"
105
+ cfg.write_text('{"local": {"command": "docker", "args": ["run", "img"]}}')
106
+ await analyze_environment_from_config(cfg, output_format="json", verbose=False)
107
+ assert client.initialize.awaited and client.shutdown.awaited
108
+
109
+
110
+ @patch("hud.clients.fastmcp.FastMCPHUDClient")
111
+ async def test_analyze_environment_from_mcp_config(MockClient):
112
+ client = AsyncMock()
113
+ client.initialize.return_value = None
114
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
115
+ client.shutdown.return_value = None
116
+ MockClient.return_value = client
117
+
118
+ mcp_config = {"local": {"command": "docker", "args": ["run", "img"]}}
119
+ await analyze_environment_from_mcp_config(mcp_config, output_format="json", verbose=False)
120
+ assert client.initialize.awaited and client.shutdown.awaited
@@ -60,12 +60,12 @@ class TestIncrementVersion:
60
60
  def test_increment_minor(self):
61
61
  """Test incrementing minor version."""
62
62
  assert increment_version("1.2.3", "minor") == "1.3.0"
63
- assert increment_version("0.5.10", "minor") == "0.6.0"
63
+ assert increment_version("0.5.13", "minor") == "0.6.0"
64
64
 
65
65
  def test_increment_major(self):
66
66
  """Test incrementing major version."""
67
67
  assert increment_version("1.2.3", "major") == "2.0.0"
68
- assert increment_version("0.5.10", "major") == "1.0.0"
68
+ assert increment_version("0.5.13", "major") == "1.0.0"
69
69
 
70
70
  def test_increment_with_v_prefix(self):
71
71
  """Test incrementing version with v prefix."""
@@ -206,7 +206,7 @@ RUN pip install fastmcp
206
206
  class TestAnalyzeMcpEnvironment:
207
207
  """Test analyzing MCP environment."""
208
208
 
209
- @mock.patch("hud.cli.build.MCPClient")
209
+ @mock.patch("hud.clients.fastmcp.FastMCPHUDClient")
210
210
  async def test_analyze_success(self, mock_client_class):
211
211
  """Test successful environment analysis."""
212
212
  # Setup mock client
@@ -219,6 +219,17 @@ class TestAnalyzeMcpEnvironment:
219
219
  mock_tool.description = "Test tool"
220
220
  mock_tool.inputSchema = {"type": "object"}
221
221
 
222
+ # Prefer analyze_environment path (aligns with analyze CLI tests)
223
+ mock_client.analyze_environment = mock.AsyncMock(
224
+ return_value={
225
+ "metadata": {"servers": ["local"], "initialized": True},
226
+ "tools": [{"name": "test_tool", "description": "Test tool"}],
227
+ "hub_tools": {},
228
+ "resources": [],
229
+ "telemetry": {},
230
+ }
231
+ )
232
+ # Fallback still defined for completeness
222
233
  mock_client.list_tools.return_value = [mock_tool]
223
234
 
224
235
  result = await analyze_mcp_environment("test:latest")
@@ -229,7 +240,7 @@ class TestAnalyzeMcpEnvironment:
229
240
  assert result["tools"][0]["name"] == "test_tool"
230
241
  assert "initializeMs" in result
231
242
 
232
- @mock.patch("hud.cli.build.MCPClient")
243
+ @mock.patch("hud.clients.fastmcp.FastMCPHUDClient")
233
244
  async def test_analyze_failure(self, mock_client_class):
234
245
  """Test failed environment analysis."""
235
246
  # Setup mock client to fail
@@ -237,14 +248,25 @@ class TestAnalyzeMcpEnvironment:
237
248
  mock_client_class.return_value = mock_client
238
249
  mock_client.initialize.side_effect = ConnectionError("Connection failed")
239
250
 
240
- with pytest.raises(ConnectionError):
251
+ from hud.shared.exceptions import HudException
252
+
253
+ with pytest.raises(HudException, match="Connection failed"):
241
254
  await analyze_mcp_environment("test:latest")
242
255
 
243
- @mock.patch("hud.cli.build.MCPClient")
256
+ @mock.patch("hud.clients.fastmcp.FastMCPHUDClient")
244
257
  async def test_analyze_verbose_mode(self, mock_client_class):
245
258
  """Test analysis in verbose mode."""
246
259
  mock_client = mock.AsyncMock()
247
260
  mock_client_class.return_value = mock_client
261
+ mock_client.analyze_environment = mock.AsyncMock(
262
+ return_value={
263
+ "metadata": {"servers": ["local"], "initialized": True},
264
+ "tools": [],
265
+ "hub_tools": {},
266
+ "resources": [],
267
+ "telemetry": {},
268
+ }
269
+ )
248
270
  mock_client.list_tools.return_value = []
249
271
 
250
272
  # Just test that it runs without error in verbose mode
@@ -312,6 +334,7 @@ class TestBuildEnvironment:
312
334
  """Test the main build_environment function."""
313
335
 
314
336
  @mock.patch("hud.cli.build.build_docker_image")
337
+ @mock.patch("hud.cli.build.collect_runtime_metadata")
315
338
  @mock.patch("hud.cli.build.analyze_mcp_environment")
316
339
  @mock.patch("hud.cli.build.save_to_registry")
317
340
  @mock.patch("hud.cli.build.get_docker_image_id")
@@ -322,6 +345,7 @@ class TestBuildEnvironment:
322
345
  mock_get_id,
323
346
  mock_save_registry,
324
347
  mock_analyze,
348
+ mock_collect_runtime,
325
349
  mock_build_docker,
326
350
  tmp_path,
327
351
  ):
@@ -356,6 +380,12 @@ ENV API_KEY
356
380
  ],
357
381
  }
358
382
  mock_get_id.return_value = "sha256:abc123"
383
+ mock_collect_runtime.return_value = {
384
+ "python": "3.11.6",
385
+ "cuda": None,
386
+ "cudnn": None,
387
+ "pytorch": None,
388
+ }
359
389
 
360
390
  # Mock final rebuild
361
391
  mock_result = mock.Mock()
@@ -363,7 +393,7 @@ ENV API_KEY
363
393
  mock_run.return_value = mock_result
364
394
 
365
395
  # Run build
366
- build_environment(str(env_dir), "test/env:latest")
396
+ build_environment(str(env_dir), "test-env:latest")
367
397
 
368
398
  # Check lock file was created
369
399
  lock_file = env_dir / "hud.lock.yaml"
@@ -373,11 +403,83 @@ ENV API_KEY
373
403
  with open(lock_file) as f:
374
404
  lock_data = yaml.safe_load(f)
375
405
 
376
- assert lock_data["image"] == "test/env:latest@sha256:abc123"
406
+ # Lock file format version
407
+ assert lock_data["version"] == "1.3"
408
+
409
+ assert lock_data["images"]["full"] == "test-env:0.1.0@sha256:abc123"
410
+ assert lock_data["images"]["local"] == "test-env:0.1.0"
377
411
  assert lock_data["build"]["version"] == "0.1.0"
412
+ assert lock_data["build"]["baseImage"] == "python:3.11"
413
+ assert lock_data["build"]["platform"] == "linux/amd64"
378
414
  assert lock_data["environment"]["toolCount"] == 2
415
+ assert lock_data["environment"]["runtime"]["python"] == "3.11.6"
379
416
  assert len(lock_data["tools"]) == 2
380
417
 
418
+ @mock.patch("hud.cli.build.build_docker_image")
419
+ @mock.patch("hud.cli.build.collect_runtime_metadata")
420
+ @mock.patch("hud.cli.build.analyze_mcp_environment")
421
+ @mock.patch("hud.cli.build.save_to_registry")
422
+ @mock.patch("hud.cli.build.get_docker_image_id")
423
+ @mock.patch("subprocess.run")
424
+ def test_build_environment_internal_tools(
425
+ self,
426
+ mock_run,
427
+ mock_get_id,
428
+ mock_save_registry,
429
+ mock_analyze,
430
+ mock_collect_runtime,
431
+ mock_build_docker,
432
+ tmp_path,
433
+ ):
434
+ """Dispatcher tools should include internalTools in lock, with count."""
435
+ env_dir = tmp_path / "env-int"
436
+ env_dir.mkdir()
437
+ (env_dir / "pyproject.toml").write_text("""
438
+ [tool.hud]
439
+ image = "test/env:dev"
440
+ """)
441
+ dockerfile = env_dir / "Dockerfile"
442
+ dockerfile.write_text("""
443
+ FROM python:3.11
444
+ """)
445
+
446
+ mock_build_docker.return_value = True
447
+ mock_analyze.return_value = {
448
+ "success": True,
449
+ "toolCount": 1,
450
+ "internalToolCount": 2,
451
+ "initializeMs": 500,
452
+ "tools": [
453
+ {
454
+ "name": "setup",
455
+ "description": "setup dispatcher",
456
+ "inputSchema": {"type": "object"},
457
+ "internalTools": ["board", "seed"],
458
+ }
459
+ ],
460
+ }
461
+ mock_get_id.return_value = "sha256:fff111"
462
+ mock_collect_runtime.return_value = {
463
+ "python": "3.11.6",
464
+ "cuda": None,
465
+ "cudnn": None,
466
+ "pytorch": None,
467
+ }
468
+
469
+ mock_result = mock.Mock()
470
+ mock_result.returncode = 0
471
+ mock_run.return_value = mock_result
472
+
473
+ build_environment(str(env_dir), "env-int:latest")
474
+
475
+ lock_file = env_dir / "hud.lock.yaml"
476
+ with open(lock_file) as f:
477
+ data = yaml.safe_load(f)
478
+ assert data["version"] == "1.3"
479
+ assert data["environment"]["internalToolCount"] == 2
480
+ assert data["tools"][0]["name"] == "setup"
481
+ assert data["tools"][0]["internalTools"] == ["board", "seed"]
482
+
381
483
  def test_build_environment_no_directory(self):
382
484
  """Test build when directory doesn't exist."""
383
485
  with pytest.raises(typer.Exit):
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ import typer
8
+
9
+ from hud.cli.build import build_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.build.compute_source_hash", return_value="deadbeef")
16
+ @patch(
17
+ "hud.cli.build.analyze_mcp_environment",
18
+ return_value={"initializeMs": 10, "toolCount": 0, "tools": []},
19
+ )
20
+ @patch("hud.cli.build.build_docker_image", return_value=True)
21
+ def test_build_label_rebuild_failure(_bd, _an, _hash, tmp_path: Path, monkeypatch):
22
+ # Minimal environment dir
23
+ env = tmp_path / "env"
24
+ env.mkdir()
25
+ (env / "Dockerfile").write_text("FROM python:3.11")
26
+
27
+ # Ensure subprocess.run returns non-zero for the second build (label build)
28
+ import types
29
+
30
+ def run_side_effect(cmd, *a, **k):
31
+ # Return 0 for first docker build, 1 for label build
32
+ if isinstance(cmd, list) and cmd[:2] == ["docker", "build"] and "--label" in cmd:
33
+ return types.SimpleNamespace(returncode=1, stderr="boom")
34
+ return types.SimpleNamespace(returncode=0, stdout="")
35
+
36
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
37
+ with (
38
+ patch("hud.cli.build.subprocess.run", side_effect=run_side_effect),
39
+ pytest.raises(typer.Exit),
40
+ ):
41
+ build_environment(str(env), verbose=False)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest import mock
5
+
6
+ from hud.cli.build import (
7
+ extract_env_vars_from_dockerfile,
8
+ get_docker_image_digest,
9
+ get_docker_image_id,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+
16
+ def test_extract_env_vars_from_dockerfile_complex(tmp_path: Path):
17
+ dockerfile = tmp_path / "Dockerfile"
18
+ dockerfile.write_text(
19
+ """
20
+ FROM python:3.11
21
+ ARG BUILD_TOKEN
22
+ ARG DEFAULTED=1
23
+ ENV RUNTIME_KEY
24
+ ENV FROM_ARG=$BUILD_TOKEN
25
+ ENV WITH_DEFAULT=val
26
+ """
27
+ )
28
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
29
+ # BUILD_TOKEN required (ARG without default)
30
+ assert "BUILD_TOKEN" in required
31
+ # RUNTIME_KEY required (ENV without value)
32
+ assert "RUNTIME_KEY" in required
33
+ # FROM_ARG references BUILD_TOKEN -> required
34
+ assert "FROM_ARG" in required
35
+ # DEFAULTED and WITH_DEFAULT should not be marked required by default
36
+ assert "DEFAULTED" not in required
37
+ assert "WITH_DEFAULT" not in required
38
+ assert optional == []
39
+
40
+
41
+ @mock.patch("subprocess.run")
42
+ def test_get_docker_image_digest_none(mock_run):
43
+ mock_run.return_value = mock.Mock(stdout="[]", returncode=0)
44
+ assert get_docker_image_digest("img") is None
45
+
46
+
47
+ @mock.patch("subprocess.run")
48
+ def test_get_docker_image_id_ok(mock_run):
49
+ mock_run.return_value = mock.Mock(stdout="sha256:abc", returncode=0)
50
+ assert get_docker_image_id("img") == "sha256:abc"
@@ -193,10 +193,15 @@ class TestCLICommands:
193
193
 
194
194
  def test_version_command(self) -> None:
195
195
  """Test version command."""
196
+ import re
197
+
198
+ # Strip ANSI escape codes
199
+ ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
196
200
  with patch("hud.__version__", "1.2.3"):
197
201
  result = runner.invoke(app, ["version"])
198
202
  assert result.exit_code == 0
199
- assert "1.2.3" in result.output
203
+ clean_output = ansi_escape.sub("", result.output)
204
+ assert "1.2.3" in clean_output
200
205
 
201
206
  def test_version_import_error(self) -> None:
202
207
  """Test version command when version unavailable."""
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ import hud.cli as cli
6
+
7
+
8
+ def test_version_does_not_crash():
9
+ # Just ensure it runs without raising
10
+ cli.version()
11
+
12
+
13
+ @patch("hud.cli.list_module.list_command")
14
+ def test_list_environments_wrapper(mock_list):
15
+ cli.list_environments(filter_name=None, json_output=False, show_all=False, verbose=False)
16
+ assert mock_list.called
17
+
18
+
19
+ @patch("hud.cli.clone_repository", return_value=(True, "/tmp/repo"))
20
+ @patch("hud.cli.get_clone_message", return_value={})
21
+ @patch("hud.cli.print_tutorial")
22
+ def test_clone_wrapper(mock_tutorial, _msg, _clone):
23
+ cli.clone("https://example.com/repo.git")
24
+ assert mock_tutorial.called
25
+
26
+
27
+ @patch("hud.cli.remove_command")
28
+ def test_remove_wrapper(mock_remove):
29
+ cli.remove(target="all", yes=True, verbose=False)
30
+ assert mock_remove.called