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.
- hud/__init__.py +27 -7
- hud/agents/__init__.py +11 -5
- hud/agents/base.py +220 -500
- hud/agents/claude.py +200 -240
- hud/agents/gemini.py +275 -0
- hud/agents/gemini_cua.py +335 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +41 -36
- hud/agents/openai.py +291 -292
- hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
- hud/agents/operator.py +211 -0
- hud/agents/tests/conftest.py +133 -0
- hud/agents/tests/test_base.py +300 -622
- hud/agents/tests/test_base_runtime.py +233 -0
- hud/agents/tests/test_claude.py +379 -210
- hud/agents/tests/test_client.py +9 -10
- hud/agents/tests/test_gemini.py +369 -0
- hud/agents/tests/test_grounded_openai_agent.py +65 -50
- hud/agents/tests/test_openai.py +376 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/cli/__init__.py +461 -545
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +664 -110
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +882 -734
- hud/cli/eval.py +782 -668
- hud/cli/flows/dev.py +167 -0
- hud/cli/flows/init.py +191 -0
- hud/cli/flows/tasks.py +153 -56
- hud/cli/flows/templates.py +151 -0
- hud/cli/flows/tests/__init__.py +1 -0
- hud/cli/flows/tests/test_dev.py +126 -0
- hud/cli/init.py +60 -58
- hud/cli/push.py +29 -11
- hud/cli/rft.py +311 -0
- hud/cli/rft_status.py +145 -0
- hud/cli/tests/test_analyze.py +5 -5
- hud/cli/tests/test_analyze_metadata.py +3 -2
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +108 -6
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_init.py +6 -1
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +140 -0
- hud/cli/tests/test_convert.py +361 -0
- hud/cli/tests/test_debug.py +12 -10
- hud/cli/tests/test_dev.py +197 -0
- hud/cli/tests/test_eval.py +251 -0
- hud/cli/tests/test_eval_bedrock.py +51 -0
- hud/cli/tests/test_init.py +124 -0
- hud/cli/tests/test_main_module.py +11 -5
- hud/cli/tests/test_mcp_server.py +12 -100
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/tests/test_registry.py +1 -1
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/{rl → utils}/celebrate.py +14 -12
- hud/cli/utils/config.py +18 -1
- hud/cli/utils/docker.py +130 -4
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/git.py +136 -0
- hud/cli/utils/interactive.py +39 -5
- hud/cli/utils/metadata.py +69 -0
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/server.py +2 -2
- hud/cli/utils/source_hash.py +3 -3
- hud/cli/utils/tasks.py +4 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_git.py +142 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +258 -0
- hud/cli/{rl → utils}/viewer.py +2 -2
- hud/clients/README.md +12 -11
- hud/clients/__init__.py +4 -3
- hud/clients/base.py +166 -26
- hud/clients/environment.py +51 -0
- hud/clients/fastmcp.py +13 -6
- hud/clients/mcp_use.py +40 -15
- hud/clients/tests/test_analyze_scenarios.py +206 -0
- hud/clients/tests/test_protocol.py +9 -3
- hud/datasets/__init__.py +23 -20
- hud/datasets/loader.py +327 -0
- hud/datasets/runner.py +192 -105
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_loader.py +221 -0
- hud/datasets/tests/test_utils.py +315 -0
- hud/datasets/utils.py +270 -90
- hud/environment/__init__.py +50 -0
- hud/environment/connection.py +206 -0
- hud/environment/connectors/__init__.py +33 -0
- hud/environment/connectors/base.py +68 -0
- hud/environment/connectors/local.py +177 -0
- hud/environment/connectors/mcp_config.py +109 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +694 -0
- hud/environment/integrations/__init__.py +45 -0
- hud/environment/integrations/adk.py +67 -0
- hud/environment/integrations/anthropic.py +196 -0
- hud/environment/integrations/gemini.py +92 -0
- hud/environment/integrations/langchain.py +82 -0
- hud/environment/integrations/llamaindex.py +68 -0
- hud/environment/integrations/openai.py +238 -0
- hud/environment/mock.py +306 -0
- hud/environment/router.py +112 -0
- hud/environment/scenarios.py +493 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +218 -0
- hud/environment/tests/test_environment.py +161 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +201 -0
- hud/environment/tests/test_scenarios.py +280 -0
- hud/environment/tests/test_tools.py +208 -0
- hud/environment/types.py +23 -0
- hud/environment/utils/__init__.py +35 -0
- hud/environment/utils/formats.py +215 -0
- hud/environment/utils/schema.py +171 -0
- hud/environment/utils/tool_wrappers.py +113 -0
- hud/eval/__init__.py +67 -0
- hud/eval/context.py +674 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +185 -0
- hud/eval/manager.py +466 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +340 -0
- hud/eval/tests/__init__.py +1 -0
- hud/eval/tests/test_context.py +178 -0
- hud/eval/tests/test_eval.py +210 -0
- hud/eval/tests/test_manager.py +152 -0
- hud/eval/tests/test_parallel.py +168 -0
- hud/eval/tests/test_task.py +145 -0
- hud/eval/types.py +63 -0
- hud/eval/utils.py +183 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +151 -0
- hud/patches/warnings.py +54 -0
- hud/samples/browser.py +4 -4
- hud/server/__init__.py +2 -1
- hud/server/low_level.py +2 -1
- hud/server/router.py +164 -0
- hud/server/server.py +567 -80
- hud/server/tests/test_mcp_server_integration.py +11 -11
- hud/server/tests/test_mcp_server_more.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/settings.py +45 -3
- hud/shared/exceptions.py +36 -10
- hud/shared/hints.py +26 -1
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +40 -31
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +20 -19
- hud/telemetry/exporter.py +201 -0
- hud/telemetry/instrument.py +158 -253
- hud/telemetry/tests/test_eval_telemetry.py +356 -0
- hud/telemetry/tests/test_exporter.py +258 -0
- hud/telemetry/tests/test_instrument.py +401 -0
- hud/tools/__init__.py +16 -2
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +4 -0
- hud/tools/computer/anthropic.py +2 -2
- hud/tools/computer/gemini.py +385 -0
- hud/tools/computer/hud.py +23 -6
- hud/tools/computer/openai.py +20 -21
- hud/tools/computer/qwen.py +434 -0
- hud/tools/computer/settings.py +37 -0
- hud/tools/edit.py +3 -7
- hud/tools/executors/base.py +4 -2
- hud/tools/executors/pyautogui.py +1 -1
- hud/tools/grounding/grounded_tool.py +13 -18
- hud/tools/grounding/grounder.py +10 -31
- hud/tools/grounding/tests/test_grounded_tool.py +26 -44
- hud/tools/jupyter.py +330 -0
- hud/tools/playwright.py +18 -3
- hud/tools/shell.py +308 -0
- hud/tools/tests/test_apply_patch.py +718 -0
- hud/tools/tests/test_computer.py +4 -9
- hud/tools/tests/test_computer_actions.py +24 -2
- hud/tools/tests/test_jupyter_tool.py +181 -0
- hud/tools/tests/test_shell.py +596 -0
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/tools/types.py +21 -1
- hud/types.py +167 -57
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +61 -3
- hud/utils/mcp.py +15 -58
- hud/utils/strict_schema.py +162 -0
- hud/utils/tests/test_init.py +1 -2
- hud/utils/tests/test_mcp.py +1 -28
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/utils/types.py +20 -0
- hud/version.py +1 -1
- hud_python-0.5.1.dist-info/METADATA +264 -0
- hud_python-0.5.1.dist-info/RECORD +299 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
- hud/agents/langchain.py +0 -261
- hud/agents/lite_llm.py +0 -72
- hud/cli/rl/__init__.py +0 -180
- hud/cli/rl/config.py +0 -101
- hud/cli/rl/display.py +0 -133
- hud/cli/rl/gpu.py +0 -63
- hud/cli/rl/gpu_utils.py +0 -321
- hud/cli/rl/local_runner.py +0 -595
- hud/cli/rl/presets.py +0 -96
- hud/cli/rl/remote_runner.py +0 -463
- hud/cli/rl/rl_api.py +0 -150
- hud/cli/rl/vllm.py +0 -177
- hud/cli/rl/wait_utils.py +0 -89
- hud/datasets/parallel.py +0 -687
- hud/misc/__init__.py +0 -1
- hud/misc/claude_plays_pokemon.py +0 -292
- hud/otel/__init__.py +0 -35
- hud/otel/collector.py +0 -142
- hud/otel/config.py +0 -181
- hud/otel/context.py +0 -570
- hud/otel/exporters.py +0 -369
- hud/otel/instrumentation.py +0 -135
- hud/otel/processors.py +0 -121
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_processors.py +0 -197
- hud/rl/README.md +0 -30
- hud/rl/__init__.py +0 -1
- hud/rl/actor.py +0 -176
- hud/rl/buffer.py +0 -405
- hud/rl/chat_template.jinja +0 -101
- hud/rl/config.py +0 -192
- hud/rl/distributed.py +0 -132
- hud/rl/learner.py +0 -637
- hud/rl/tests/__init__.py +0 -1
- hud/rl/tests/test_learner.py +0 -186
- hud/rl/train.py +0 -382
- hud/rl/types.py +0 -101
- hud/rl/utils/start_vllm_server.sh +0 -30
- hud/rl/utils.py +0 -524
- hud/rl/vllm_adapter.py +0 -143
- hud/telemetry/job.py +0 -352
- hud/telemetry/replay.py +0 -74
- hud/telemetry/tests/test_replay.py +0 -40
- hud/telemetry/tests/test_trace.py +0 -63
- hud/telemetry/trace.py +0 -158
- hud/utils/agent_factories.py +0 -86
- hud/utils/async_utils.py +0 -65
- hud/utils/group_eval.py +0 -223
- hud/utils/progress.py +0 -149
- hud/utils/tasks.py +0 -127
- hud/utils/tests/test_async_utils.py +0 -173
- hud/utils/tests/test_progress.py +0 -261
- hud_python-0.4.45.dist-info/METADATA +0 -552
- hud_python-0.4.45.dist-info/RECORD +0 -228
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
hud/cli/tests/test_mcp_server.py
CHANGED
|
@@ -2,124 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from
|
|
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
|
-
"""
|
|
105
|
-
import
|
|
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.
|
|
109
|
-
patch("
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
+
)
|
hud/cli/tests/test_registry.py
CHANGED
|
@@ -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.
|
|
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
|
|
hud/cli/tests/test_utils.py
CHANGED
|
@@ -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[
|
|
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
|
|
126
|
+
def show_confetti(console: Console, seconds: float = 2.5, message: str | None = None) -> None:
|
|
127
|
+
"""Display celebratory confetti animation.
|
|
126
128
|
|
|
127
|
-
Shows
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
-
|
|
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
|
|
193
|
-
|
|
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)
|
|
321
|
+
raise typer.Exit(1) # noqa: B904
|
hud/cli/utils/env_check.py
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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("
|
|
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
|