hud-python 0.4.45__py3-none-any.whl → 0.5.1__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 (274) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +11 -5
  3. hud/agents/base.py +220 -500
  4. hud/agents/claude.py +200 -240
  5. hud/agents/gemini.py +275 -0
  6. hud/agents/gemini_cua.py +335 -0
  7. hud/agents/grounded_openai.py +98 -100
  8. hud/agents/misc/integration_test_agent.py +51 -20
  9. hud/agents/misc/response_agent.py +41 -36
  10. hud/agents/openai.py +291 -292
  11. hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
  12. hud/agents/operator.py +211 -0
  13. hud/agents/tests/conftest.py +133 -0
  14. hud/agents/tests/test_base.py +300 -622
  15. hud/agents/tests/test_base_runtime.py +233 -0
  16. hud/agents/tests/test_claude.py +379 -210
  17. hud/agents/tests/test_client.py +9 -10
  18. hud/agents/tests/test_gemini.py +369 -0
  19. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  20. hud/agents/tests/test_openai.py +376 -140
  21. hud/agents/tests/test_operator.py +362 -0
  22. hud/agents/tests/test_run_eval.py +179 -0
  23. hud/cli/__init__.py +461 -545
  24. hud/cli/analyze.py +43 -5
  25. hud/cli/build.py +664 -110
  26. hud/cli/debug.py +8 -5
  27. hud/cli/dev.py +882 -734
  28. hud/cli/eval.py +782 -668
  29. hud/cli/flows/dev.py +167 -0
  30. hud/cli/flows/init.py +191 -0
  31. hud/cli/flows/tasks.py +153 -56
  32. hud/cli/flows/templates.py +151 -0
  33. hud/cli/flows/tests/__init__.py +1 -0
  34. hud/cli/flows/tests/test_dev.py +126 -0
  35. hud/cli/init.py +60 -58
  36. hud/cli/push.py +29 -11
  37. hud/cli/rft.py +311 -0
  38. hud/cli/rft_status.py +145 -0
  39. hud/cli/tests/test_analyze.py +5 -5
  40. hud/cli/tests/test_analyze_metadata.py +3 -2
  41. hud/cli/tests/test_analyze_module.py +120 -0
  42. hud/cli/tests/test_build.py +108 -6
  43. hud/cli/tests/test_build_failure.py +41 -0
  44. hud/cli/tests/test_build_module.py +50 -0
  45. hud/cli/tests/test_cli_init.py +6 -1
  46. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  47. hud/cli/tests/test_cli_root.py +140 -0
  48. hud/cli/tests/test_convert.py +361 -0
  49. hud/cli/tests/test_debug.py +12 -10
  50. hud/cli/tests/test_dev.py +197 -0
  51. hud/cli/tests/test_eval.py +251 -0
  52. hud/cli/tests/test_eval_bedrock.py +51 -0
  53. hud/cli/tests/test_init.py +124 -0
  54. hud/cli/tests/test_main_module.py +11 -5
  55. hud/cli/tests/test_mcp_server.py +12 -100
  56. hud/cli/tests/test_push_happy.py +74 -0
  57. hud/cli/tests/test_push_wrapper.py +23 -0
  58. hud/cli/tests/test_registry.py +1 -1
  59. hud/cli/tests/test_utils.py +1 -1
  60. hud/cli/{rl → utils}/celebrate.py +14 -12
  61. hud/cli/utils/config.py +18 -1
  62. hud/cli/utils/docker.py +130 -4
  63. hud/cli/utils/env_check.py +9 -9
  64. hud/cli/utils/git.py +136 -0
  65. hud/cli/utils/interactive.py +39 -5
  66. hud/cli/utils/metadata.py +69 -0
  67. hud/cli/utils/runner.py +1 -1
  68. hud/cli/utils/server.py +2 -2
  69. hud/cli/utils/source_hash.py +3 -3
  70. hud/cli/utils/tasks.py +4 -1
  71. hud/cli/utils/tests/__init__.py +0 -0
  72. hud/cli/utils/tests/test_config.py +58 -0
  73. hud/cli/utils/tests/test_docker.py +93 -0
  74. hud/cli/utils/tests/test_docker_hints.py +71 -0
  75. hud/cli/utils/tests/test_env_check.py +74 -0
  76. hud/cli/utils/tests/test_environment.py +42 -0
  77. hud/cli/utils/tests/test_git.py +142 -0
  78. hud/cli/utils/tests/test_interactive_module.py +60 -0
  79. hud/cli/utils/tests/test_local_runner.py +50 -0
  80. hud/cli/utils/tests/test_logging_utils.py +23 -0
  81. hud/cli/utils/tests/test_metadata.py +49 -0
  82. hud/cli/utils/tests/test_package_runner.py +35 -0
  83. hud/cli/utils/tests/test_registry_utils.py +49 -0
  84. hud/cli/utils/tests/test_remote_runner.py +25 -0
  85. hud/cli/utils/tests/test_runner_modules.py +52 -0
  86. hud/cli/utils/tests/test_source_hash.py +36 -0
  87. hud/cli/utils/tests/test_tasks.py +80 -0
  88. hud/cli/utils/version_check.py +258 -0
  89. hud/cli/{rl → utils}/viewer.py +2 -2
  90. hud/clients/README.md +12 -11
  91. hud/clients/__init__.py +4 -3
  92. hud/clients/base.py +166 -26
  93. hud/clients/environment.py +51 -0
  94. hud/clients/fastmcp.py +13 -6
  95. hud/clients/mcp_use.py +40 -15
  96. hud/clients/tests/test_analyze_scenarios.py +206 -0
  97. hud/clients/tests/test_protocol.py +9 -3
  98. hud/datasets/__init__.py +23 -20
  99. hud/datasets/loader.py +327 -0
  100. hud/datasets/runner.py +192 -105
  101. hud/datasets/tests/__init__.py +0 -0
  102. hud/datasets/tests/test_loader.py +221 -0
  103. hud/datasets/tests/test_utils.py +315 -0
  104. hud/datasets/utils.py +270 -90
  105. hud/environment/__init__.py +50 -0
  106. hud/environment/connection.py +206 -0
  107. hud/environment/connectors/__init__.py +33 -0
  108. hud/environment/connectors/base.py +68 -0
  109. hud/environment/connectors/local.py +177 -0
  110. hud/environment/connectors/mcp_config.py +109 -0
  111. hud/environment/connectors/openai.py +101 -0
  112. hud/environment/connectors/remote.py +172 -0
  113. hud/environment/environment.py +694 -0
  114. hud/environment/integrations/__init__.py +45 -0
  115. hud/environment/integrations/adk.py +67 -0
  116. hud/environment/integrations/anthropic.py +196 -0
  117. hud/environment/integrations/gemini.py +92 -0
  118. hud/environment/integrations/langchain.py +82 -0
  119. hud/environment/integrations/llamaindex.py +68 -0
  120. hud/environment/integrations/openai.py +238 -0
  121. hud/environment/mock.py +306 -0
  122. hud/environment/router.py +112 -0
  123. hud/environment/scenarios.py +493 -0
  124. hud/environment/tests/__init__.py +1 -0
  125. hud/environment/tests/test_connection.py +317 -0
  126. hud/environment/tests/test_connectors.py +218 -0
  127. hud/environment/tests/test_environment.py +161 -0
  128. hud/environment/tests/test_integrations.py +257 -0
  129. hud/environment/tests/test_local_connectors.py +201 -0
  130. hud/environment/tests/test_scenarios.py +280 -0
  131. hud/environment/tests/test_tools.py +208 -0
  132. hud/environment/types.py +23 -0
  133. hud/environment/utils/__init__.py +35 -0
  134. hud/environment/utils/formats.py +215 -0
  135. hud/environment/utils/schema.py +171 -0
  136. hud/environment/utils/tool_wrappers.py +113 -0
  137. hud/eval/__init__.py +67 -0
  138. hud/eval/context.py +674 -0
  139. hud/eval/display.py +299 -0
  140. hud/eval/instrument.py +185 -0
  141. hud/eval/manager.py +466 -0
  142. hud/eval/parallel.py +268 -0
  143. hud/eval/task.py +340 -0
  144. hud/eval/tests/__init__.py +1 -0
  145. hud/eval/tests/test_context.py +178 -0
  146. hud/eval/tests/test_eval.py +210 -0
  147. hud/eval/tests/test_manager.py +152 -0
  148. hud/eval/tests/test_parallel.py +168 -0
  149. hud/eval/tests/test_task.py +145 -0
  150. hud/eval/types.py +63 -0
  151. hud/eval/utils.py +183 -0
  152. hud/patches/__init__.py +19 -0
  153. hud/patches/mcp_patches.py +151 -0
  154. hud/patches/warnings.py +54 -0
  155. hud/samples/browser.py +4 -4
  156. hud/server/__init__.py +2 -1
  157. hud/server/low_level.py +2 -1
  158. hud/server/router.py +164 -0
  159. hud/server/server.py +567 -80
  160. hud/server/tests/test_mcp_server_integration.py +11 -11
  161. hud/server/tests/test_mcp_server_more.py +1 -1
  162. hud/server/tests/test_server_extra.py +2 -0
  163. hud/settings.py +45 -3
  164. hud/shared/exceptions.py +36 -10
  165. hud/shared/hints.py +26 -1
  166. hud/shared/requests.py +15 -3
  167. hud/shared/tests/test_exceptions.py +40 -31
  168. hud/shared/tests/test_hints.py +167 -0
  169. hud/telemetry/__init__.py +20 -19
  170. hud/telemetry/exporter.py +201 -0
  171. hud/telemetry/instrument.py +158 -253
  172. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  173. hud/telemetry/tests/test_exporter.py +258 -0
  174. hud/telemetry/tests/test_instrument.py +401 -0
  175. hud/tools/__init__.py +16 -2
  176. hud/tools/apply_patch.py +639 -0
  177. hud/tools/base.py +54 -4
  178. hud/tools/bash.py +2 -2
  179. hud/tools/computer/__init__.py +4 -0
  180. hud/tools/computer/anthropic.py +2 -2
  181. hud/tools/computer/gemini.py +385 -0
  182. hud/tools/computer/hud.py +23 -6
  183. hud/tools/computer/openai.py +20 -21
  184. hud/tools/computer/qwen.py +434 -0
  185. hud/tools/computer/settings.py +37 -0
  186. hud/tools/edit.py +3 -7
  187. hud/tools/executors/base.py +4 -2
  188. hud/tools/executors/pyautogui.py +1 -1
  189. hud/tools/grounding/grounded_tool.py +13 -18
  190. hud/tools/grounding/grounder.py +10 -31
  191. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  192. hud/tools/jupyter.py +330 -0
  193. hud/tools/playwright.py +18 -3
  194. hud/tools/shell.py +308 -0
  195. hud/tools/tests/test_apply_patch.py +718 -0
  196. hud/tools/tests/test_computer.py +4 -9
  197. hud/tools/tests/test_computer_actions.py +24 -2
  198. hud/tools/tests/test_jupyter_tool.py +181 -0
  199. hud/tools/tests/test_shell.py +596 -0
  200. hud/tools/tests/test_submit.py +85 -0
  201. hud/tools/tests/test_types.py +193 -0
  202. hud/tools/types.py +21 -1
  203. hud/types.py +167 -57
  204. hud/utils/__init__.py +2 -0
  205. hud/utils/env.py +67 -0
  206. hud/utils/hud_console.py +61 -3
  207. hud/utils/mcp.py +15 -58
  208. hud/utils/strict_schema.py +162 -0
  209. hud/utils/tests/test_init.py +1 -2
  210. hud/utils/tests/test_mcp.py +1 -28
  211. hud/utils/tests/test_pretty_errors.py +186 -0
  212. hud/utils/tests/test_tool_shorthand.py +154 -0
  213. hud/utils/tests/test_version.py +1 -1
  214. hud/utils/types.py +20 -0
  215. hud/version.py +1 -1
  216. hud_python-0.5.1.dist-info/METADATA +264 -0
  217. hud_python-0.5.1.dist-info/RECORD +299 -0
  218. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
  219. hud/agents/langchain.py +0 -261
  220. hud/agents/lite_llm.py +0 -72
  221. hud/cli/rl/__init__.py +0 -180
  222. hud/cli/rl/config.py +0 -101
  223. hud/cli/rl/display.py +0 -133
  224. hud/cli/rl/gpu.py +0 -63
  225. hud/cli/rl/gpu_utils.py +0 -321
  226. hud/cli/rl/local_runner.py +0 -595
  227. hud/cli/rl/presets.py +0 -96
  228. hud/cli/rl/remote_runner.py +0 -463
  229. hud/cli/rl/rl_api.py +0 -150
  230. hud/cli/rl/vllm.py +0 -177
  231. hud/cli/rl/wait_utils.py +0 -89
  232. hud/datasets/parallel.py +0 -687
  233. hud/misc/__init__.py +0 -1
  234. hud/misc/claude_plays_pokemon.py +0 -292
  235. hud/otel/__init__.py +0 -35
  236. hud/otel/collector.py +0 -142
  237. hud/otel/config.py +0 -181
  238. hud/otel/context.py +0 -570
  239. hud/otel/exporters.py +0 -369
  240. hud/otel/instrumentation.py +0 -135
  241. hud/otel/processors.py +0 -121
  242. hud/otel/tests/__init__.py +0 -1
  243. hud/otel/tests/test_processors.py +0 -197
  244. hud/rl/README.md +0 -30
  245. hud/rl/__init__.py +0 -1
  246. hud/rl/actor.py +0 -176
  247. hud/rl/buffer.py +0 -405
  248. hud/rl/chat_template.jinja +0 -101
  249. hud/rl/config.py +0 -192
  250. hud/rl/distributed.py +0 -132
  251. hud/rl/learner.py +0 -637
  252. hud/rl/tests/__init__.py +0 -1
  253. hud/rl/tests/test_learner.py +0 -186
  254. hud/rl/train.py +0 -382
  255. hud/rl/types.py +0 -101
  256. hud/rl/utils/start_vllm_server.sh +0 -30
  257. hud/rl/utils.py +0 -524
  258. hud/rl/vllm_adapter.py +0 -143
  259. hud/telemetry/job.py +0 -352
  260. hud/telemetry/replay.py +0 -74
  261. hud/telemetry/tests/test_replay.py +0 -40
  262. hud/telemetry/tests/test_trace.py +0 -63
  263. hud/telemetry/trace.py +0 -158
  264. hud/utils/agent_factories.py +0 -86
  265. hud/utils/async_utils.py +0 -65
  266. hud/utils/group_eval.py +0 -223
  267. hud/utils/progress.py +0 -149
  268. hud/utils/tasks.py +0 -127
  269. hud/utils/tests/test_async_utils.py +0 -173
  270. hud/utils/tests/test_progress.py +0 -261
  271. hud_python-0.4.45.dist-info/METADATA +0 -552
  272. hud_python-0.4.45.dist-info/RECORD +0 -228
  273. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
  274. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,124 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path # noqa: TC003
6
- from unittest.mock import MagicMock, patch
5
+ from unittest.mock import patch
7
6
 
8
7
  import pytest
9
8
 
10
9
  from hud.cli.dev import (
11
- create_proxy_server,
12
- get_docker_cmd,
13
- get_image_name,
14
10
  run_mcp_dev_server,
15
- update_pyproject_toml,
16
11
  )
17
12
 
18
13
 
19
- class TestCreateMCPServer:
20
- """Test MCP server creation."""
21
-
22
- def test_create_mcp_server(self) -> None:
23
- """Test that MCP server is created with correct configuration."""
24
- mcp = create_proxy_server(".", "test-image:latest")
25
- assert mcp._mcp_server.name == "HUD Dev Proxy - test-image:latest"
26
- # Proxy server doesn't define its own tools, it forwards to Docker containers
27
-
28
-
29
- class TestDockerUtils:
30
- """Test Docker utility functions."""
31
-
32
- def test_get_docker_cmd(self) -> None:
33
- """Test extracting CMD from Docker image."""
34
- with patch("subprocess.run") as mock_run:
35
- mock_result = MagicMock()
36
- mock_result.returncode = 0
37
- mock_result.stdout = '["python", "-m", "server"]'
38
- mock_run.return_value = mock_result
39
-
40
- cmd = get_docker_cmd("test-image:latest")
41
- assert cmd is None
42
-
43
- def test_get_docker_cmd_failure(self) -> None:
44
- """Test handling when Docker inspect fails."""
45
- import subprocess
46
-
47
- with patch("subprocess.run") as mock_run:
48
- # check=True causes CalledProcessError on non-zero return
49
- mock_run.side_effect = subprocess.CalledProcessError(1, "docker inspect")
50
-
51
- cmd = get_docker_cmd("test-image:latest")
52
- assert cmd is None
53
-
54
-
55
- class TestImageResolution:
56
- """Test image name resolution."""
57
-
58
- def test_get_image_name_override(self) -> None:
59
- """Test image name with override."""
60
- name, source = get_image_name(".", "custom-image:v1")
61
- assert name == "custom-image:v1"
62
- assert source == "override"
63
-
64
- def test_get_image_name_from_pyproject(self, tmp_path: Path) -> None:
65
- """Test image name from pyproject.toml."""
66
- pyproject = tmp_path / "pyproject.toml"
67
- pyproject.write_text("""
68
- [tool.hud]
69
- image = "my-project:latest"
70
- """)
71
-
72
- name, source = get_image_name(str(tmp_path))
73
- assert name == "my-project:latest"
74
- assert source == "cache"
75
-
76
- def test_get_image_name_auto_generate(self, tmp_path: Path) -> None:
77
- """Test auto-generated image name."""
78
- test_dir = tmp_path / "my_test_project"
79
- test_dir.mkdir()
80
-
81
- name, source = get_image_name(str(test_dir))
82
- assert name == "my-test-project:dev"
83
- assert source == "auto"
84
-
85
- def test_update_pyproject_toml(self, tmp_path: Path) -> None:
86
- """Test updating pyproject.toml with image name."""
87
- pyproject = tmp_path / "pyproject.toml"
88
- pyproject.write_text("""
89
- [project]
90
- name = "test"
91
- """)
92
-
93
- update_pyproject_toml(str(tmp_path), "new-image:v1", silent=True)
94
-
95
- content = pyproject.read_text()
96
- assert "[tool.hud]" in content
97
- assert 'image = "new-image:v1"' in content
98
-
99
-
100
14
  class TestRunMCPDevServer:
101
15
  """Test the main server runner."""
102
16
 
103
17
  def test_run_dev_server_image_not_found(self) -> None:
104
- """Test handling when Docker image doesn't exist."""
105
- import click
18
+ """When using Docker mode without a lock file, exits with typer.Exit(1)."""
19
+ import typer
106
20
 
107
21
  with (
108
- patch("hud.cli.dev.image_exists", return_value=False),
109
- patch("click.confirm", return_value=False),
110
- pytest.raises(click.Abort),
22
+ patch("hud.cli.dev.should_use_docker_mode", return_value=True),
23
+ patch("hud.cli.dev.Path.cwd"),
24
+ patch("hud.cli.dev.hud_console"),
25
+ pytest.raises(typer.Exit),
111
26
  ):
112
27
  run_mcp_dev_server(
113
- directory=".",
114
- image="missing:latest",
115
- build=False,
116
- no_cache=False,
117
- transport="http",
28
+ module=None,
29
+ stdio=False,
118
30
  port=8765,
119
- no_reload=False,
120
31
  verbose=False,
121
32
  inspector=False,
122
- no_logs=False,
123
- docker_args=[],
124
33
  interactive=False,
34
+ watch=[],
35
+ docker=True,
36
+ docker_args=[],
125
37
  )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import TYPE_CHECKING
5
+ from unittest.mock import patch
6
+
7
+ from hud.cli.push import push_environment
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ @patch("hud.cli.push.get_docker_username", return_value="tester")
14
+ @patch(
15
+ "hud.cli.push.get_docker_image_labels",
16
+ return_value={"org.hud.manifest.head": "abc", "org.hud.version": "0.1.0"},
17
+ )
18
+ @patch("hud.cli.push.requests.post")
19
+ @patch("hud.cli.push.subprocess.Popen")
20
+ @patch("hud.cli.push.subprocess.run")
21
+ def test_push_happy_path(
22
+ mock_run, mock_popen, mock_post, _labels, _user, tmp_path: Path, monkeypatch
23
+ ):
24
+ # Prepare minimal environment with lock file
25
+ env_dir = tmp_path
26
+ (env_dir / "hud.lock.yaml").write_text(
27
+ "images:\n local: org/env:latest\nbuild:\n version: 0.1.0\n"
28
+ )
29
+
30
+ # Provide API key via main settings module
31
+ monkeypatch.setattr("hud.settings.settings.api_key", "sk-test", raising=False)
32
+
33
+ # ensure_built noop - patch from the right module
34
+ monkeypatch.setattr("hud.cli.utils.env_check.ensure_built", lambda *_a, **_k: {})
35
+
36
+ # Mock subprocess.run behavior depending on command
37
+ def run_side_effect(args, *a, **k):
38
+ cmd = list(args)
39
+ # docker inspect checks
40
+ if cmd[:2] == ["docker", "inspect"]:
41
+ # For label digest query at end
42
+ if "--format" in cmd and "{{index .RepoDigests 0}}" in cmd:
43
+ return SimpleNamespace(returncode=0, stdout="org/env@sha256:deadbeef")
44
+ # Existence checks succeed
45
+ return SimpleNamespace(returncode=0, stdout="")
46
+ # docker tag success
47
+ if cmd[:2] == ["docker", "tag"]:
48
+ return SimpleNamespace(returncode=0, stdout="")
49
+ return SimpleNamespace(returncode=0, stdout="")
50
+
51
+ mock_run.side_effect = run_side_effect
52
+
53
+ # Mock Popen push pipeline
54
+ class _Proc:
55
+ def __init__(self):
56
+ self.stdout = ["digest: sha256:deadbeef\n", "pushed\n"]
57
+ self.returncode = 0
58
+
59
+ def wait(self):
60
+ return 0
61
+
62
+ mock_popen.return_value = _Proc()
63
+
64
+ # Mock registry POST success
65
+ mock_post.return_value = SimpleNamespace(status_code=201, json=lambda: {"ok": True}, text="")
66
+
67
+ # Execute
68
+ push_environment(
69
+ directory=str(env_dir), image=None, tag=None, sign=False, yes=True, verbose=False
70
+ )
71
+
72
+ # Lock file updated with pushed entry
73
+ data = (env_dir / "hud.lock.yaml").read_text()
74
+ assert "pushed:" in data
@@ -0,0 +1,23 @@
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.push import push_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.push.ensure_built")
16
+ @patch("hud.cli.push.HUDConsole")
17
+ @patch("hud.cli.push.subprocess.run")
18
+ def test_push_environment_missing_lock_raises(mock_run, mock_console, _ensure, tmp_path: Path):
19
+ # No hud.lock.yaml → Exit(1)
20
+ with pytest.raises(typer.Exit):
21
+ push_environment(
22
+ directory=str(tmp_path), image=None, tag=None, sign=False, yes=True, verbose=False
23
+ )
@@ -189,7 +189,7 @@ class TestLoadFromRegistry:
189
189
  digest_dir = registry_dir / "abc123"
190
190
  digest_dir.mkdir(parents=True)
191
191
 
192
- lock_data = {"image": "test:latest", "version": "1.0"}
192
+ lock_data = {"image": "test:latest", "version": "1.3"}
193
193
  lock_file = digest_dir / "hud.lock.yaml"
194
194
  lock_file.write_text(yaml.dump(lock_data))
195
195
 
@@ -22,7 +22,7 @@ class TestColors:
22
22
  assert Colors.YELLOW == "\033[93m"
23
23
  assert Colors.GOLD == "\033[33m"
24
24
  assert Colors.RED == "\033[91m"
25
- assert Colors.GRAY == "\033[90m"
25
+ assert Colors.GRAY == "\033[37m"
26
26
  assert Colors.ENDC == "\033[0m"
27
27
  assert Colors.BOLD == "\033[1m"
28
28
 
@@ -1,4 +1,6 @@
1
1
  # ruff: noqa: S311
2
+ """Confetti celebration animation for CLI."""
3
+
2
4
  from __future__ import annotations
3
5
 
4
6
  import random
@@ -121,20 +123,20 @@ class ConfettiSystem:
121
123
  return text
122
124
 
123
125
 
124
- def show_confetti(console: Console, seconds: float = 2.5) -> None:
125
- """Display celebratory confetti animation inspired by confetty.
126
+ def show_confetti(console: Console, seconds: float = 2.5, message: str | None = None) -> None:
127
+ """Display celebratory confetti animation.
126
128
 
127
- Shows "Starting training!" message first, then creates two bursts of
129
+ Shows a message first, then creates two bursts of
128
130
  falling confetti particles that fall away completely.
129
131
 
130
132
  Args:
131
133
  console: Rich console instance
132
134
  seconds: Duration to show confetti
135
+ message: Custom message to display (default: "🎉 Success!")
133
136
  """
134
137
  # Show celebratory message first
135
- console.print(
136
- "[bold green]🎉 Starting training! See your model on https://hud.so/models[/bold green]"
137
- )
138
+ msg = message or "[bold green]🎉 Success![/bold green]"
139
+ console.print(msg)
138
140
  time.sleep(0.3) # Brief pause to see the message
139
141
 
140
142
  width = min(console.size.width, 120) # Cap width for performance
@@ -166,22 +168,22 @@ def show_confetti(console: Console, seconds: float = 2.5) -> None:
166
168
  frame += 1
167
169
 
168
170
 
169
- def show_confetti_async(console: Console, seconds: float = 2.5) -> None:
171
+ def show_confetti_async(console: Console, seconds: float = 2.5, message: str | None = None) -> None:
170
172
  """Non-blocking confetti animation that runs in a background thread.
171
173
 
172
- The animation will run independently while training starts immediately.
174
+ The animation will run independently while other operations continue.
173
175
  """
174
176
  import threading
175
177
 
176
178
  def _run_confetti() -> None:
177
179
  try:
178
- show_confetti(console, seconds)
180
+ show_confetti(console, seconds, message)
179
181
  except Exception:
180
- hud_console.info("Launching training...")
182
+ hud_console.info("Continuing...")
181
183
 
182
184
  thread = threading.Thread(target=_run_confetti, daemon=True)
183
185
  thread.start()
184
- # Don't wait - let training start immediately while confetti plays
186
+ # Don't wait - let operations continue while confetti plays
185
187
 
186
188
 
187
- __all__ = ["show_confetti", "show_confetti_async"]
189
+ __all__ = ["ConfettiSystem", "Particle", "show_confetti", "show_confetti_async"]
hud/cli/utils/config.py CHANGED
@@ -27,7 +27,9 @@ def parse_env_file(contents: str) -> dict[str, str]:
27
27
  """Parse simple KEY=VALUE lines into a dict.
28
28
 
29
29
  - Ignores blank lines and lines starting with '#'.
30
- - Does not perform variable substitution or quoting.
30
+ - Strips inline comments (# and everything after) from unquoted values.
31
+ - Respects single and double quoted values (comments inside quotes are preserved).
32
+ - Does not perform variable substitution.
31
33
  """
32
34
  data: dict[str, str] = {}
33
35
  for raw_line in contents.splitlines():
@@ -39,6 +41,21 @@ def parse_env_file(contents: str) -> dict[str, str]:
39
41
  key, value = line.split("=", 1)
40
42
  key = key.strip()
41
43
  value = value.strip()
44
+
45
+ # Handle quoted values - preserve everything inside quotes
46
+ if value and value[0] in ('"', "'"):
47
+ quote_char = value[0]
48
+ # Find the closing quote
49
+ end_quote = value.find(quote_char, 1)
50
+ # Extract value without quotes (or strip opening quote if no closing quote)
51
+ value = value[1:end_quote] if end_quote != -1 else value[1:]
52
+ else:
53
+ # Unquoted value - strip inline comments
54
+ # Find # that's not escaped and treat as comment start
55
+ comment_idx = value.find("#")
56
+ if comment_idx != -1:
57
+ value = value[:comment_idx].rstrip()
58
+
42
59
  if key:
43
60
  data[key] = value
44
61
  return data
hud/cli/utils/docker.py CHANGED
@@ -1,4 +1,9 @@
1
- """Docker utilities for HUD CLI."""
1
+ """Docker utilities for HUD CLI.
2
+
3
+ This module centralizes helpers for constructing Docker commands and
4
+ standardizes environment variable handling for "folder mode" (environment
5
+ directories that include a `.env` file and/or `hud.lock.yaml`).
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
@@ -6,6 +11,12 @@ import json
6
11
  import platform
7
12
  import shutil
8
13
  import subprocess
14
+ from pathlib import Path
15
+
16
+ from .config import parse_env_file
17
+
18
+ # Note: we deliberately avoid the stricter is_environment_directory() check here
19
+ # to allow folder mode with only a Dockerfile or only a pyproject.toml.
9
20
 
10
21
 
11
22
  def get_docker_cmd(image: str) -> list[str] | None:
@@ -103,6 +114,118 @@ def build_run_command(image: str, docker_args: list[str] | None = None) -> list[
103
114
  ]
104
115
 
105
116
 
117
+ def detect_environment_dir(start_dir: Path | None = None) -> Path | None:
118
+ """Detect an environment directory for folder mode.
119
+
120
+ Detection order:
121
+ - Current directory containing `hud.lock.yaml`
122
+ - Parent directory containing `hud.lock.yaml`
123
+ - Current directory that looks like an environment if it has either a
124
+ `Dockerfile.hud`, `Dockerfile`, or a `pyproject.toml` (looser than `is_environment_directory`)
125
+
126
+ Returns the detected directory path or None if not found.
127
+ """
128
+ base = (start_dir or Path.cwd()).resolve()
129
+
130
+ # Check current then parent for lock file
131
+ for candidate in [base, base.parent]:
132
+ if (candidate / "hud.lock.yaml").exists():
133
+ return candidate
134
+
135
+ # Fallback: treat as env if it has Dockerfile.hud, Dockerfile, or pyproject.toml
136
+ if (
137
+ (base / "Dockerfile.hud").exists()
138
+ or (base / "Dockerfile").exists()
139
+ or (base / "pyproject.toml").exists()
140
+ ):
141
+ return base
142
+
143
+ return None
144
+
145
+
146
+ def load_env_vars_for_dir(env_dir: Path) -> dict[str, str]:
147
+ """Load KEY=VALUE pairs from `<env_dir>/.env` if present.
148
+
149
+ Returns an empty dict if no file is found or parsing fails.
150
+ """
151
+ env_file = env_dir / ".env"
152
+ if not env_file.exists():
153
+ return {}
154
+ try:
155
+ contents = env_file.read_text(encoding="utf-8")
156
+ return parse_env_file(contents)
157
+ except Exception:
158
+ return {}
159
+
160
+
161
+ def build_env_flags(env_vars: dict[str, str]) -> list[str]:
162
+ """Convert an env dict into a flat list of `-e KEY=VALUE` flags."""
163
+ flags: list[str] = []
164
+ for key, value in env_vars.items():
165
+ flags.extend(["-e", f"{key}={value}"])
166
+ return flags
167
+
168
+
169
+ def create_docker_run_command(
170
+ image: str,
171
+ docker_args: list[str] | None = None,
172
+ env_dir: Path | str | None = None,
173
+ extra_env: dict[str, str] | None = None,
174
+ name: str | None = None,
175
+ interactive: bool = True,
176
+ remove: bool = True,
177
+ ) -> list[str]:
178
+ """Create a standardized `docker run` command with folder-mode envs.
179
+
180
+ - If `env_dir` is provided (or auto-detected), `.env` entries are injected as
181
+ `-e KEY=VALUE` flags before the image.
182
+ - `extra_env` allows callers to provide additional env pairs that override
183
+ variables from `.env`.
184
+
185
+ Args:
186
+ image: Docker image to run
187
+ docker_args: Additional docker args (volumes, ports, etc.)
188
+ env_dir: Environment directory to load `.env` from; if None, auto-detect
189
+ extra_env: Additional env variables to inject (takes precedence)
190
+ name: Optional container name
191
+ interactive: Include `-i` flag (default True)
192
+ remove: Include `--rm` flag (default True)
193
+
194
+ Returns:
195
+ Fully constructed docker run command
196
+ """
197
+ cmd: list[str] = ["docker", "run"]
198
+ if remove:
199
+ cmd.append("--rm")
200
+ if interactive:
201
+ cmd.append("-i")
202
+ if name:
203
+ cmd.extend(["--name", name])
204
+
205
+ # Load env from `.env` in detected env directory
206
+ env_dir_path: Path | None = (
207
+ Path(env_dir).resolve() if isinstance(env_dir, str | Path) else detect_environment_dir()
208
+ )
209
+
210
+ merged_env: dict[str, str] = {}
211
+ if env_dir_path is not None:
212
+ merged_env.update(load_env_vars_for_dir(env_dir_path))
213
+ if extra_env:
214
+ # Caller-provided values override .env
215
+ merged_env.update(extra_env)
216
+
217
+ # Insert env flags before other args
218
+ if merged_env:
219
+ cmd.extend(build_env_flags(merged_env))
220
+
221
+ # Add remaining args (volumes, ports, etc.)
222
+ if docker_args:
223
+ cmd.extend(docker_args)
224
+
225
+ cmd.append(image)
226
+ return cmd
227
+
228
+
106
229
  def _emit_docker_hints(error_text: str) -> None:
107
230
  """Parse common Docker connectivity errors and print platform-specific hints."""
108
231
  from hud.utils.hud_console import hud_console
@@ -189,7 +312,10 @@ def require_docker_running() -> None:
189
312
  "Is Docker running? Open Docker Desktop and wait until it reports 'Running'"
190
313
  )
191
314
  raise typer.Exit(1) from e
192
- except Exception as e:
193
- hud_console.error(f"Docker check failed: {e}")
315
+ except typer.Exit:
316
+ # Propagate cleanly without extra noise; hints already printed above
317
+ raise
318
+ except Exception:
319
+ # Unknown failure - keep output minimal and avoid stack traces
194
320
  hud_console.hint("Is the Docker daemon running?")
195
- raise typer.Exit(1) from e
321
+ raise typer.Exit(1) # noqa: B904
@@ -175,16 +175,16 @@ def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
175
175
  _print_section("Added files", diffs.get("added", []))
176
176
  _print_section("Removed files", diffs.get("removed", []))
177
177
 
178
- if interactive:
179
- if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
- require_docker_running()
181
- build_environment(str(env_dir), platform="linux/amd64")
182
- with open(lock_path) as f:
183
- lock_data = yaml.safe_load(f) or {}
184
- else:
185
- hud_console.hint("Continuing without rebuild; this may use an outdated image.")
178
+ # if interactive:
179
+ if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
+ require_docker_running()
181
+ build_environment(str(env_dir), platform="linux/amd64")
182
+ with open(lock_path) as f:
183
+ lock_data = yaml.safe_load(f) or {}
186
184
  else:
187
- hud_console.hint("Run 'hud build' to update the image before proceeding.")
185
+ hud_console.hint("Continuing without rebuild; this may use an outdated image.")
186
+ # else:
187
+ # hud_console.hint("Run 'hud build' to update the image before proceeding.")
188
188
  elif not stored_hash:
189
189
  hud_console.dim_info(
190
190
  "Info",
hud/cli/utils/git.py ADDED
@@ -0,0 +1,136 @@
1
+ """Git utilities for extracting repository information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def get_git_remote_url(cwd: Path | None = None) -> str | None:
14
+ """
15
+ Get the git remote origin URL for the current repository.
16
+
17
+ Args:
18
+ cwd: Working directory (defaults to current directory)
19
+
20
+ Returns:
21
+ Git remote URL if available, None otherwise
22
+ """
23
+ cwd = cwd or Path.cwd()
24
+
25
+ try:
26
+ # Check if we're in a git repository
27
+ subprocess.run(
28
+ ["git", "rev-parse", "--git-dir"], # noqa: S607
29
+ cwd=cwd,
30
+ capture_output=True,
31
+ check=True,
32
+ )
33
+
34
+ # Get the remote origin URL
35
+ result = subprocess.run(
36
+ ["git", "config", "--get", "remote.origin.url"], # noqa: S607
37
+ cwd=cwd,
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+
43
+ url = result.stdout.strip()
44
+ if url:
45
+ return normalize_github_url(url)
46
+ return None
47
+
48
+ except subprocess.CalledProcessError:
49
+ # Not a git repository or no remote origin
50
+ return None
51
+ except Exception as e:
52
+ logger.debug("Error getting git remote URL: %s", e)
53
+ return None
54
+
55
+
56
+ def normalize_github_url(url: str) -> str:
57
+ """
58
+ Normalize various git URL formats to standard HTTPS GitHub URL.
59
+
60
+ Examples:
61
+ git@github.com:user/repo.git -> https://github.com/user/repo
62
+ https://github.com/user/repo.git -> https://github.com/user/repo
63
+ git://github.com/user/repo.git -> https://github.com/user/repo
64
+
65
+ Args:
66
+ url: Git remote URL in any format
67
+
68
+ Returns:
69
+ Normalized HTTPS GitHub URL
70
+ """
71
+ # Remove trailing .git
72
+ if url.endswith(".git"):
73
+ url = url[:-4]
74
+
75
+ # Handle SSH format (git@github.com:user/repo)
76
+ if url.startswith("git@github.com:"):
77
+ url = url.replace("git@github.com:", "https://github.com/")
78
+
79
+ # Handle git:// protocol
80
+ elif url.startswith("git://"):
81
+ url = url.replace("git://", "https://")
82
+
83
+ # Ensure HTTPS
84
+ elif not url.startswith("https://") and "github.com:" in url:
85
+ parts = url.split("github.com:")
86
+ url = f"https://github.com/{parts[1]}"
87
+
88
+ return url
89
+
90
+
91
+ def get_git_info(cwd: Path | None = None) -> dict[str, Any]:
92
+ """
93
+ Get comprehensive git repository information.
94
+
95
+ Args:
96
+ cwd: Working directory (defaults to current directory)
97
+
98
+ Returns:
99
+ Dictionary with git info including:
100
+ - remote_url: The remote origin URL
101
+ - branch: Current branch name
102
+ - commit: Current commit hash (short)
103
+ """
104
+ cwd = cwd or Path.cwd()
105
+ info: dict[str, Any] = {}
106
+
107
+ # Get remote URL
108
+ info["remote_url"] = get_git_remote_url(cwd)
109
+
110
+ try:
111
+ # Get current branch
112
+ result = subprocess.run(
113
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
114
+ cwd=cwd,
115
+ capture_output=True,
116
+ text=True,
117
+ check=True,
118
+ )
119
+ info["branch"] = result.stdout.strip()
120
+
121
+ # Get current commit (short hash)
122
+ result = subprocess.run(
123
+ ["git", "rev-parse", "--short", "HEAD"], # noqa: S607
124
+ cwd=cwd,
125
+ capture_output=True,
126
+ text=True,
127
+ check=True,
128
+ )
129
+ info["commit"] = result.stdout.strip()
130
+
131
+ except subprocess.CalledProcessError:
132
+ pass
133
+ except Exception as e:
134
+ logger.debug("Error getting git info: %s", e)
135
+
136
+ return info