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/build.py
CHANGED
|
@@ -5,9 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
7
|
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
8
11
|
import subprocess
|
|
9
12
|
import time
|
|
10
|
-
from datetime import datetime
|
|
13
|
+
from datetime import UTC, datetime
|
|
11
14
|
from pathlib import Path
|
|
12
15
|
from typing import Any
|
|
13
16
|
|
|
@@ -15,13 +18,36 @@ import typer
|
|
|
15
18
|
import yaml
|
|
16
19
|
|
|
17
20
|
from hud.cli.utils.source_hash import compute_source_hash, list_source_files
|
|
18
|
-
from hud.clients import MCPClient
|
|
19
21
|
from hud.utils.hud_console import HUDConsole
|
|
20
22
|
from hud.version import __version__ as hud_version
|
|
21
23
|
|
|
22
24
|
from .utils.registry import save_to_registry
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def find_dockerfile(directory: Path) -> Path | None:
|
|
28
|
+
"""Find the Dockerfile in a directory, preferring Dockerfile.hud.
|
|
29
|
+
|
|
30
|
+
Checks for Dockerfile.hud first (HUD-specific), then falls back to Dockerfile.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
directory: Directory to search in
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to the Dockerfile if found, None otherwise
|
|
37
|
+
"""
|
|
38
|
+
# Prefer Dockerfile.hud for HUD environments
|
|
39
|
+
hud_dockerfile = directory / "Dockerfile.hud"
|
|
40
|
+
if hud_dockerfile.exists():
|
|
41
|
+
return hud_dockerfile
|
|
42
|
+
|
|
43
|
+
# Fall back to standard Dockerfile
|
|
44
|
+
standard_dockerfile = directory / "Dockerfile"
|
|
45
|
+
if standard_dockerfile.exists():
|
|
46
|
+
return standard_dockerfile
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
25
51
|
def parse_version(version_str: str) -> tuple[int, int, int]:
|
|
26
52
|
"""Parse version string like '1.0.0' or '1.0' into tuple of integers."""
|
|
27
53
|
# Remove 'v' prefix if present
|
|
@@ -50,6 +76,140 @@ def increment_version(version_str: str, increment_type: str = "patch") -> str:
|
|
|
50
76
|
return f"{major}.{minor}.{patch + 1}"
|
|
51
77
|
|
|
52
78
|
|
|
79
|
+
def find_task_files_in_env(env_dir: Path) -> list[Path]:
|
|
80
|
+
"""Find all task files in an environment directory.
|
|
81
|
+
|
|
82
|
+
This looks for .json and .jsonl files that contain task definitions,
|
|
83
|
+
excluding config files and lock files.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
env_dir: Environment directory to search
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of task file paths
|
|
90
|
+
"""
|
|
91
|
+
task_files: list[Path] = []
|
|
92
|
+
|
|
93
|
+
# Find all .json and .jsonl files
|
|
94
|
+
json_files = list(env_dir.glob("*.json")) + list(env_dir.glob("*.jsonl"))
|
|
95
|
+
|
|
96
|
+
# Filter out config files and lock files
|
|
97
|
+
for file in json_files:
|
|
98
|
+
# Skip hidden files, config files, and lock files
|
|
99
|
+
if (
|
|
100
|
+
file.name.startswith(".")
|
|
101
|
+
or file.name == "package.json"
|
|
102
|
+
or file.name == "tsconfig.json"
|
|
103
|
+
or file.name == "gcp.json"
|
|
104
|
+
or file.name.endswith(".lock.json")
|
|
105
|
+
):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Check if it's a task file by looking for mcp_config
|
|
109
|
+
try:
|
|
110
|
+
with open(file, encoding="utf-8") as f:
|
|
111
|
+
content = json.load(f)
|
|
112
|
+
|
|
113
|
+
# It's a task file if it's a list with mcp_config entries
|
|
114
|
+
if (
|
|
115
|
+
isinstance(content, list)
|
|
116
|
+
and len(content) > 0
|
|
117
|
+
and any(isinstance(item, dict) and "mcp_config" in item for item in content)
|
|
118
|
+
):
|
|
119
|
+
task_files.append(file)
|
|
120
|
+
except (json.JSONDecodeError, Exception): # noqa: S112
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
return task_files
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def update_tasks_json_versions(
|
|
127
|
+
env_dir: Path, base_name: str, old_version: str | None, new_version: str
|
|
128
|
+
) -> list[Path]:
|
|
129
|
+
"""Update image references in tasks.json files to use the new version.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
env_dir: Environment directory
|
|
133
|
+
base_name: Base image name (without version)
|
|
134
|
+
old_version: Previous version (if any)
|
|
135
|
+
new_version: New version to use
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of updated task files
|
|
139
|
+
"""
|
|
140
|
+
hud_console = HUDConsole()
|
|
141
|
+
updated_files: list[Path] = []
|
|
142
|
+
|
|
143
|
+
for task_file in find_task_files_in_env(env_dir):
|
|
144
|
+
try:
|
|
145
|
+
with open(task_file, encoding="utf-8") as f:
|
|
146
|
+
tasks = json.load(f)
|
|
147
|
+
if not isinstance(tasks, list):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
modified = False
|
|
151
|
+
|
|
152
|
+
# Process each task
|
|
153
|
+
for task in tasks:
|
|
154
|
+
if not isinstance(task, dict) or "mcp_config" not in task:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
mcp_config = task["mcp_config"]
|
|
158
|
+
|
|
159
|
+
# Handle local Docker format
|
|
160
|
+
if "local" in mcp_config and isinstance(mcp_config["local"], dict):
|
|
161
|
+
local_config = mcp_config["local"]
|
|
162
|
+
|
|
163
|
+
# Check for docker run args
|
|
164
|
+
if "args" in local_config and isinstance(local_config["args"], list):
|
|
165
|
+
for i, arg in enumerate(local_config["args"]):
|
|
166
|
+
# Match image references
|
|
167
|
+
if isinstance(arg, str) and (
|
|
168
|
+
arg == f"{base_name}:latest"
|
|
169
|
+
or (old_version and arg == f"{base_name}:{old_version}")
|
|
170
|
+
or re.match(rf"^{re.escape(base_name)}:\d+\.\d+\.\d+$", arg)
|
|
171
|
+
):
|
|
172
|
+
# Update to new version
|
|
173
|
+
local_config["args"][i] = f"{base_name}:{new_version}"
|
|
174
|
+
modified = True
|
|
175
|
+
|
|
176
|
+
# Handle HUD API format (remote MCP)
|
|
177
|
+
elif "hud" in mcp_config and isinstance(mcp_config["hud"], dict):
|
|
178
|
+
hud_config = mcp_config["hud"]
|
|
179
|
+
|
|
180
|
+
# Check headers for Mcp-Image
|
|
181
|
+
if "headers" in hud_config and isinstance(hud_config["headers"], dict):
|
|
182
|
+
headers = hud_config["headers"]
|
|
183
|
+
|
|
184
|
+
if "Mcp-Image" in headers:
|
|
185
|
+
image_ref = headers["Mcp-Image"]
|
|
186
|
+
|
|
187
|
+
# Match various image formats
|
|
188
|
+
if isinstance(image_ref, str) and ":" in image_ref:
|
|
189
|
+
# Split into image name and tag
|
|
190
|
+
image_name, _ = image_ref.rsplit(":", 1)
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
image_name == base_name # Exact match
|
|
194
|
+
or image_name.endswith(f"/{base_name}") # With prefix
|
|
195
|
+
):
|
|
196
|
+
# Update to new version, preserving the full image path
|
|
197
|
+
headers["Mcp-Image"] = f"{image_name}:{new_version}"
|
|
198
|
+
modified = True
|
|
199
|
+
|
|
200
|
+
# Save the file if modified
|
|
201
|
+
if modified:
|
|
202
|
+
with open(task_file, "w") as f:
|
|
203
|
+
json.dump(tasks, f, indent=2)
|
|
204
|
+
updated_files.append(task_file)
|
|
205
|
+
hud_console.success(f"Updated {task_file.name} with version {new_version}")
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
hud_console.warning(f"Could not update {task_file.name}: {e}")
|
|
209
|
+
|
|
210
|
+
return updated_files
|
|
211
|
+
|
|
212
|
+
|
|
53
213
|
def get_existing_version(lock_path: Path) -> str | None:
|
|
54
214
|
"""Get the internal version from existing lock file if it exists."""
|
|
55
215
|
if not lock_path.exists():
|
|
@@ -154,6 +314,121 @@ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str],
|
|
|
154
314
|
return required, optional
|
|
155
315
|
|
|
156
316
|
|
|
317
|
+
def parse_base_image(dockerfile_path: Path) -> str | None:
|
|
318
|
+
"""Extract the base image from the first FROM directive in Dockerfile.
|
|
319
|
+
|
|
320
|
+
For multi-stage builds, returns the image from the first FROM. Strips any
|
|
321
|
+
trailing AS <stage> segment.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
if not dockerfile_path.exists():
|
|
325
|
+
return None
|
|
326
|
+
for raw_line in dockerfile_path.read_text().splitlines():
|
|
327
|
+
line = raw_line.strip()
|
|
328
|
+
if not line or line.startswith("#"):
|
|
329
|
+
continue
|
|
330
|
+
if line.upper().startswith("FROM "):
|
|
331
|
+
rest = line[5:].strip()
|
|
332
|
+
# Remove stage alias if present
|
|
333
|
+
lower = rest.lower()
|
|
334
|
+
if " as " in lower:
|
|
335
|
+
# Split using the original case string at the index of lower-case match
|
|
336
|
+
idx = lower.index(" as ")
|
|
337
|
+
rest = rest[:idx]
|
|
338
|
+
return rest.strip()
|
|
339
|
+
except Exception:
|
|
340
|
+
return None
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def collect_runtime_metadata(image: str, *, verbose: bool = False) -> dict[str, str | None]:
|
|
345
|
+
"""Probe container to capture Python/CUDA/cuDNN/PyTorch versions.
|
|
346
|
+
|
|
347
|
+
Runs a tiny Python snippet inside the built image using docker run.
|
|
348
|
+
"""
|
|
349
|
+
hud_console = HUDConsole()
|
|
350
|
+
|
|
351
|
+
runtime_script = (
|
|
352
|
+
"import json, platform\n"
|
|
353
|
+
"info = {'python': platform.python_version()}\n"
|
|
354
|
+
"try:\n"
|
|
355
|
+
" import torch\n"
|
|
356
|
+
" info['pytorch'] = getattr(torch, '__version__', None)\n"
|
|
357
|
+
" cuda_version = None\n"
|
|
358
|
+
" try:\n"
|
|
359
|
+
" cuda_version = getattr(getattr(torch, 'version', None), 'cuda', None)\n"
|
|
360
|
+
" except Exception:\n"
|
|
361
|
+
" cuda_version = None\n"
|
|
362
|
+
" if cuda_version:\n"
|
|
363
|
+
" info['cuda'] = cuda_version\n"
|
|
364
|
+
" try:\n"
|
|
365
|
+
" cudnn_version = torch.backends.cudnn.version()\n"
|
|
366
|
+
" except Exception:\n"
|
|
367
|
+
" cudnn_version = None\n"
|
|
368
|
+
" if cudnn_version:\n"
|
|
369
|
+
" info['cudnn'] = str(cudnn_version)\n"
|
|
370
|
+
"except Exception:\n"
|
|
371
|
+
" pass\n"
|
|
372
|
+
"info.setdefault('pytorch', None)\n"
|
|
373
|
+
"info.setdefault('cuda', None)\n"
|
|
374
|
+
"info.setdefault('cudnn', None)\n"
|
|
375
|
+
"print(json.dumps(info))\n"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
for binary in ("python", "python3"):
|
|
379
|
+
cmd = [
|
|
380
|
+
"docker",
|
|
381
|
+
"run",
|
|
382
|
+
"--rm",
|
|
383
|
+
image,
|
|
384
|
+
binary,
|
|
385
|
+
"-c",
|
|
386
|
+
runtime_script,
|
|
387
|
+
]
|
|
388
|
+
try:
|
|
389
|
+
result = subprocess.run( # noqa: S603
|
|
390
|
+
cmd, capture_output=True, text=True, check=False
|
|
391
|
+
)
|
|
392
|
+
except FileNotFoundError:
|
|
393
|
+
return {}
|
|
394
|
+
|
|
395
|
+
if result.returncode != 0:
|
|
396
|
+
if verbose:
|
|
397
|
+
hud_console.debug(
|
|
398
|
+
f"Runtime probe failed with {binary}: {result.stderr.strip() or 'no stderr'}"
|
|
399
|
+
)
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
output = (result.stdout or "").strip()
|
|
403
|
+
if not output:
|
|
404
|
+
return {}
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
data = json.loads(output.splitlines()[-1])
|
|
408
|
+
except json.JSONDecodeError:
|
|
409
|
+
if verbose:
|
|
410
|
+
hud_console.debug(
|
|
411
|
+
"Runtime probe returned non-JSON output; skipping metadata capture"
|
|
412
|
+
)
|
|
413
|
+
return {}
|
|
414
|
+
|
|
415
|
+
if not isinstance(data, dict):
|
|
416
|
+
if verbose:
|
|
417
|
+
hud_console.debug(
|
|
418
|
+
"Runtime probe returned JSON that is not an object; skipping metadata capture"
|
|
419
|
+
)
|
|
420
|
+
return {}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"python": data.get("python"),
|
|
424
|
+
"cuda": data.get("cuda"),
|
|
425
|
+
"cudnn": data.get("cudnn"),
|
|
426
|
+
"pytorch": data.get("pytorch"),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {}
|
|
430
|
+
|
|
431
|
+
|
|
157
432
|
async def analyze_mcp_environment(
|
|
158
433
|
image: str, verbose: bool = False, env_vars: dict[str, str] | None = None
|
|
159
434
|
) -> dict[str, Any]:
|
|
@@ -161,50 +436,103 @@ async def analyze_mcp_environment(
|
|
|
161
436
|
hud_console = HUDConsole()
|
|
162
437
|
env_vars = env_vars or {}
|
|
163
438
|
|
|
164
|
-
# Build Docker command to run the image
|
|
165
|
-
|
|
439
|
+
# Build Docker command to run the image, injecting any provided env vars
|
|
440
|
+
from hud.cli.utils.docker import build_env_flags
|
|
166
441
|
|
|
167
|
-
|
|
168
|
-
for key, value in env_vars.items():
|
|
169
|
-
docker_cmd.extend(["-e", f"{key}={value}"])
|
|
442
|
+
docker_cmd = ["docker", "run", "--rm", "-i", *build_env_flags(env_vars), image]
|
|
170
443
|
|
|
171
|
-
|
|
444
|
+
# Show full docker command being used for analysis
|
|
445
|
+
hud_console.dim_info("Command:", " ".join(docker_cmd))
|
|
172
446
|
|
|
173
|
-
# Create MCP config
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
447
|
+
# Create MCP config consistently with analyze helpers
|
|
448
|
+
from hud.cli.analyze import parse_docker_command
|
|
449
|
+
|
|
450
|
+
mcp_config = parse_docker_command(docker_cmd)
|
|
177
451
|
|
|
178
452
|
# Initialize client and measure timing
|
|
453
|
+
# Use FastMCP client directly - no mcp_use deprecation warnings
|
|
454
|
+
from hud.clients.fastmcp import FastMCPHUDClient
|
|
455
|
+
|
|
179
456
|
start_time = time.time()
|
|
180
|
-
client =
|
|
457
|
+
client = FastMCPHUDClient(mcp_config=mcp_config, verbose=verbose)
|
|
181
458
|
initialized = False
|
|
182
459
|
|
|
183
460
|
try:
|
|
184
461
|
if verbose:
|
|
185
|
-
hud_console.info(
|
|
462
|
+
hud_console.info("Initializing MCP client...")
|
|
186
463
|
|
|
187
|
-
|
|
464
|
+
# Add timeout to fail fast instead of hanging (60 seconds)
|
|
465
|
+
await asyncio.wait_for(client.initialize(), timeout=60.0)
|
|
188
466
|
initialized = True
|
|
189
467
|
initialize_ms = int((time.time() - start_time) * 1000)
|
|
190
468
|
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
469
|
+
# Delegate to standard analysis helper
|
|
470
|
+
full_analysis = await client.analyze_environment()
|
|
471
|
+
|
|
472
|
+
# Normalize and enrich with internalTools if a hub map is present
|
|
473
|
+
tools_list = full_analysis.get("tools", [])
|
|
474
|
+
hub_map = full_analysis.get("hub_tools", {}) or full_analysis.get("hubTools", {})
|
|
475
|
+
|
|
476
|
+
normalized_tools: list[dict[str, Any]] = []
|
|
477
|
+
internal_total = 0
|
|
478
|
+
for t in tools_list:
|
|
479
|
+
# Extract core fields (support object or dict forms)
|
|
480
|
+
if hasattr(t, "name"):
|
|
481
|
+
name = getattr(t, "name", None)
|
|
482
|
+
description = getattr(t, "description", None)
|
|
483
|
+
input_schema = getattr(t, "inputSchema", None)
|
|
484
|
+
existing_internal = getattr(t, "internalTools", None)
|
|
485
|
+
else:
|
|
486
|
+
name = t.get("name")
|
|
487
|
+
description = t.get("description")
|
|
488
|
+
# accept either inputSchema or input_schema
|
|
489
|
+
input_schema = t.get("inputSchema") or t.get("input_schema")
|
|
490
|
+
# accept either internalTools or internal_tools
|
|
491
|
+
existing_internal = t.get("internalTools") or t.get("internal_tools")
|
|
492
|
+
|
|
493
|
+
tool_entry: dict[str, Any] = {"name": name}
|
|
494
|
+
if description:
|
|
495
|
+
tool_entry["description"] = description
|
|
496
|
+
if input_schema:
|
|
497
|
+
tool_entry["inputSchema"] = input_schema
|
|
498
|
+
|
|
499
|
+
# Merge internal tools: preserve any existing declaration and add hub_map[name]
|
|
500
|
+
merged_internal: list[str] = []
|
|
501
|
+
if isinstance(existing_internal, list):
|
|
502
|
+
merged_internal.extend([str(x) for x in existing_internal])
|
|
503
|
+
if isinstance(hub_map, dict) and name in hub_map and isinstance(hub_map[name], list):
|
|
504
|
+
merged_internal.extend([str(x) for x in hub_map[name]])
|
|
505
|
+
if merged_internal:
|
|
506
|
+
# Deduplicate while preserving order
|
|
507
|
+
merged_internal = list(dict.fromkeys(merged_internal))
|
|
508
|
+
tool_entry["internalTools"] = merged_internal
|
|
509
|
+
internal_total += len(merged_internal)
|
|
510
|
+
|
|
511
|
+
normalized_tools.append(tool_entry)
|
|
512
|
+
|
|
513
|
+
result = {
|
|
203
514
|
"initializeMs": initialize_ms,
|
|
204
|
-
"toolCount": len(
|
|
205
|
-
"
|
|
515
|
+
"toolCount": len(tools_list),
|
|
516
|
+
"internalToolCount": internal_total,
|
|
517
|
+
"tools": normalized_tools,
|
|
206
518
|
"success": True,
|
|
207
519
|
}
|
|
520
|
+
if hub_map:
|
|
521
|
+
result["hub_tools"] = hub_map
|
|
522
|
+
# Include prompts and resources from analysis
|
|
523
|
+
if full_analysis.get("prompts"):
|
|
524
|
+
result["prompts"] = full_analysis["prompts"]
|
|
525
|
+
if full_analysis.get("resources"):
|
|
526
|
+
result["resources"] = full_analysis["resources"]
|
|
527
|
+
return result
|
|
528
|
+
except TimeoutError:
|
|
529
|
+
from hud.shared.exceptions import HudException
|
|
530
|
+
|
|
531
|
+
hud_console.error("MCP server initialization timed out after 60 seconds")
|
|
532
|
+
hud_console.info(
|
|
533
|
+
"The server likely crashed during startup - check stderr logs with 'hud debug'"
|
|
534
|
+
)
|
|
535
|
+
raise HudException("MCP server initialization timeout") from None
|
|
208
536
|
except Exception as e:
|
|
209
537
|
from hud.shared.exceptions import HudException
|
|
210
538
|
|
|
@@ -227,28 +555,76 @@ def build_docker_image(
|
|
|
227
555
|
verbose: bool = False,
|
|
228
556
|
build_args: dict[str, str] | None = None,
|
|
229
557
|
platform: str | None = None,
|
|
558
|
+
remote_cache: str | None = None,
|
|
230
559
|
) -> bool:
|
|
231
560
|
"""Build a Docker image from a directory."""
|
|
232
561
|
hud_console = HUDConsole()
|
|
233
562
|
build_args = build_args or {}
|
|
234
563
|
|
|
235
|
-
# Check if Dockerfile exists
|
|
236
|
-
dockerfile = directory
|
|
237
|
-
if
|
|
564
|
+
# Check if Dockerfile exists (prefer Dockerfile.hud)
|
|
565
|
+
dockerfile = find_dockerfile(directory)
|
|
566
|
+
if dockerfile is None:
|
|
238
567
|
hud_console.error(f"No Dockerfile found in {directory}")
|
|
568
|
+
hud_console.info("Expected: Dockerfile.hud or Dockerfile")
|
|
239
569
|
return False
|
|
240
570
|
|
|
241
|
-
#
|
|
571
|
+
# Build command - use buildx when remote cache is enabled
|
|
242
572
|
effective_platform = platform if platform is not None else "linux/amd64"
|
|
573
|
+
cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
|
|
574
|
+
|
|
575
|
+
# Specify dockerfile explicitly if not the default name
|
|
576
|
+
if dockerfile.name != "Dockerfile":
|
|
577
|
+
cmd.extend(["-f", str(dockerfile)])
|
|
243
578
|
|
|
244
|
-
# Build command
|
|
245
|
-
cmd = ["docker", "build"]
|
|
246
579
|
if effective_platform:
|
|
247
580
|
cmd.extend(["--platform", effective_platform])
|
|
248
581
|
cmd.extend(["-t", tag])
|
|
249
582
|
if no_cache:
|
|
250
583
|
cmd.append("--no-cache")
|
|
251
584
|
|
|
585
|
+
# Add remote cache support for ECR
|
|
586
|
+
if remote_cache:
|
|
587
|
+
try:
|
|
588
|
+
# Validate ECR repo name
|
|
589
|
+
if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
|
|
590
|
+
hud_console.error(f"Invalid ECR repo name: {remote_cache}")
|
|
591
|
+
hud_console.info(
|
|
592
|
+
"ECR repo names must contain only lowercase letters, numbers, hyphens, underscores, and forward slashes" # noqa: E501
|
|
593
|
+
)
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
# Get required environment variables
|
|
597
|
+
aws_account_id = os.getenv("AWS_ACCOUNT_ID")
|
|
598
|
+
aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
599
|
+
|
|
600
|
+
if not aws_account_id:
|
|
601
|
+
hud_console.error("AWS_ACCOUNT_ID environment variable not set")
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
# ECR cache image reference
|
|
605
|
+
cache_image = (
|
|
606
|
+
f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Add cache arguments with proper ECR format
|
|
610
|
+
cmd.extend(
|
|
611
|
+
[
|
|
612
|
+
"--cache-from",
|
|
613
|
+
f"type=registry,ref={cache_image}",
|
|
614
|
+
"--cache-to",
|
|
615
|
+
f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
|
|
616
|
+
"--load", # Load image to local Docker after build
|
|
617
|
+
]
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
hud_console.success(f"Remote cache configured: {cache_image}")
|
|
621
|
+
|
|
622
|
+
except typer.Exit:
|
|
623
|
+
raise
|
|
624
|
+
except Exception as e:
|
|
625
|
+
hud_console.error(f"Remote cache setup error: {e}")
|
|
626
|
+
return False
|
|
627
|
+
|
|
252
628
|
# Add build args
|
|
253
629
|
for key, value in build_args.items():
|
|
254
630
|
cmd.extend(["--build-arg", f"{key}={value}"])
|
|
@@ -274,6 +650,7 @@ def build_environment(
|
|
|
274
650
|
verbose: bool = False,
|
|
275
651
|
env_vars: dict[str, str] | None = None,
|
|
276
652
|
platform: str | None = None,
|
|
653
|
+
remote_cache: str | None = None,
|
|
277
654
|
) -> None:
|
|
278
655
|
"""Build a HUD environment and generate lock file."""
|
|
279
656
|
hud_console = HUDConsole()
|
|
@@ -286,26 +663,52 @@ def build_environment(
|
|
|
286
663
|
hud_console.error(f"Directory not found: {directory}")
|
|
287
664
|
raise typer.Exit(1)
|
|
288
665
|
|
|
289
|
-
|
|
290
|
-
pyproject_path = env_dir / "pyproject.toml"
|
|
291
|
-
if not pyproject_path.exists():
|
|
292
|
-
hud_console.error(f"No pyproject.toml found in {directory}")
|
|
293
|
-
raise typer.Exit(1)
|
|
294
|
-
|
|
295
|
-
# Read pyproject.toml to get image name
|
|
296
|
-
try:
|
|
297
|
-
import toml
|
|
666
|
+
from hud.cli.utils.docker import require_docker_running
|
|
298
667
|
|
|
299
|
-
|
|
300
|
-
default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
|
|
301
|
-
if not default_image:
|
|
302
|
-
# Generate default from directory name
|
|
303
|
-
default_image = f"{env_dir.name}:dev"
|
|
304
|
-
except Exception:
|
|
305
|
-
default_image = f"{env_dir.name}:dev"
|
|
668
|
+
require_docker_running()
|
|
306
669
|
|
|
307
|
-
#
|
|
308
|
-
|
|
670
|
+
# Step 1: Check for hud.lock.yaml (previous build)
|
|
671
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
672
|
+
base_name = None
|
|
673
|
+
|
|
674
|
+
if lock_path.exists():
|
|
675
|
+
try:
|
|
676
|
+
with open(lock_path) as f:
|
|
677
|
+
lock_data = yaml.safe_load(f)
|
|
678
|
+
# Get base name from lock file (strip version/digest)
|
|
679
|
+
lock_image = lock_data.get("images", {}).get("local") or lock_data.get("image", "")
|
|
680
|
+
if lock_image:
|
|
681
|
+
# Remove @sha256:... digest if present
|
|
682
|
+
if "@" in lock_image:
|
|
683
|
+
lock_image = lock_image.split("@")[0]
|
|
684
|
+
# Extract base name (remove :version tag)
|
|
685
|
+
base_name = lock_image.split(":")[0] if ":" in lock_image else lock_image
|
|
686
|
+
hud_console.info(f"Using base name from lock file: {base_name}")
|
|
687
|
+
except Exception as e:
|
|
688
|
+
hud_console.warning(f"Could not read lock file: {e}")
|
|
689
|
+
|
|
690
|
+
# Step 2: If no lock, check for Dockerfile
|
|
691
|
+
if not base_name:
|
|
692
|
+
dockerfile_path = find_dockerfile(env_dir)
|
|
693
|
+
if dockerfile_path is None:
|
|
694
|
+
hud_console.error(f"Not a valid environment directory: {directory}")
|
|
695
|
+
hud_console.info("Expected: Dockerfile.hud, Dockerfile, or hud.lock.yaml")
|
|
696
|
+
raise typer.Exit(1)
|
|
697
|
+
|
|
698
|
+
# First build - use directory name
|
|
699
|
+
base_name = env_dir.name
|
|
700
|
+
hud_console.info(f"First build - using base name: {base_name}")
|
|
701
|
+
if dockerfile_path.name == "Dockerfile.hud":
|
|
702
|
+
hud_console.info("Using Dockerfile.hud")
|
|
703
|
+
|
|
704
|
+
# If user provides --tag, respect it; otherwise use base name only (version added later)
|
|
705
|
+
if tag:
|
|
706
|
+
# User explicitly provided a tag
|
|
707
|
+
image_tag = tag
|
|
708
|
+
base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
|
|
709
|
+
else:
|
|
710
|
+
# No tag provided - we'll add version later
|
|
711
|
+
image_tag = None
|
|
309
712
|
|
|
310
713
|
# Build temporary image first
|
|
311
714
|
temp_tag = f"hud-build-temp:{int(time.time())}"
|
|
@@ -320,50 +723,77 @@ def build_environment(
|
|
|
320
723
|
verbose,
|
|
321
724
|
build_args=None,
|
|
322
725
|
platform=platform,
|
|
726
|
+
remote_cache=remote_cache,
|
|
323
727
|
):
|
|
324
728
|
hud_console.error("Docker build failed")
|
|
325
729
|
raise typer.Exit(1)
|
|
326
730
|
|
|
327
731
|
hud_console.success(f"Built temporary image: {temp_tag}")
|
|
328
732
|
|
|
329
|
-
# Analyze the environment
|
|
733
|
+
# Analyze the environment (merge folder .env if present)
|
|
330
734
|
hud_console.progress_message("Analyzing MCP environment...")
|
|
331
735
|
|
|
332
736
|
loop = asyncio.new_event_loop()
|
|
333
737
|
asyncio.set_event_loop(loop)
|
|
334
738
|
try:
|
|
335
|
-
|
|
739
|
+
# Merge .env from env_dir for analysis only
|
|
740
|
+
try:
|
|
741
|
+
from hud.cli.utils.docker import load_env_vars_for_dir
|
|
742
|
+
|
|
743
|
+
env_from_file = load_env_vars_for_dir(env_dir)
|
|
744
|
+
except Exception:
|
|
745
|
+
env_from_file = {}
|
|
746
|
+
merged_env_for_analysis = {**env_from_file, **(env_vars or {})}
|
|
747
|
+
|
|
748
|
+
analysis = loop.run_until_complete(
|
|
749
|
+
analyze_mcp_environment(temp_tag, verbose, merged_env_for_analysis)
|
|
750
|
+
)
|
|
751
|
+
except Exception as e:
|
|
752
|
+
hud_console.error(f"Failed to analyze MCP environment: {e}")
|
|
753
|
+
hud_console.info("")
|
|
754
|
+
hud_console.info("To debug this issue, run:")
|
|
755
|
+
hud_console.command_example(f"hud debug {temp_tag}")
|
|
756
|
+
hud_console.info("")
|
|
757
|
+
raise typer.Exit(1) from e
|
|
336
758
|
finally:
|
|
337
759
|
loop.close()
|
|
338
760
|
|
|
339
|
-
|
|
761
|
+
# Show analysis results including hub tools, prompts, resources
|
|
762
|
+
tool_count = analysis["toolCount"]
|
|
763
|
+
prompt_count = len(analysis.get("prompts") or [])
|
|
764
|
+
resource_count = len(analysis.get("resources") or [])
|
|
765
|
+
|
|
766
|
+
parts = [f"{tool_count} tools"]
|
|
767
|
+
if prompt_count:
|
|
768
|
+
parts.append(f"{prompt_count} prompts")
|
|
769
|
+
if resource_count:
|
|
770
|
+
parts.append(f"{resource_count} resources")
|
|
771
|
+
|
|
772
|
+
tool_msg = f"Analyzed environment: {', '.join(parts)} found"
|
|
773
|
+
hud_console.success(tool_msg)
|
|
340
774
|
|
|
341
775
|
# Extract environment variables from Dockerfile
|
|
342
|
-
dockerfile_path = env_dir / "Dockerfile"
|
|
776
|
+
dockerfile_path = find_dockerfile(env_dir) or env_dir / "Dockerfile"
|
|
343
777
|
required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
|
|
344
778
|
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
provided_env_vars = {k: f"${{{k}}}" for k in env_vars}
|
|
351
|
-
# Track which required vars are still missing
|
|
352
|
-
missing_required = [e for e in required_env if e not in env_vars]
|
|
353
|
-
|
|
354
|
-
# Show what env vars were provided
|
|
355
|
-
hud_console.success(f"Using provided environment variables: {', '.join(env_vars.keys())}")
|
|
356
|
-
else:
|
|
357
|
-
missing_required = required_env[:]
|
|
779
|
+
# Show env vars detected from .env file
|
|
780
|
+
if env_from_file:
|
|
781
|
+
hud_console.info(
|
|
782
|
+
f"Detected environment variables from .env file: {', '.join(sorted(env_from_file.keys()))}" # noqa: E501
|
|
783
|
+
)
|
|
358
784
|
|
|
359
|
-
#
|
|
360
|
-
|
|
785
|
+
# Create a complete set of all required variables for warning
|
|
786
|
+
all_required_for_warning = set(required_env)
|
|
787
|
+
all_required_for_warning.update(env_from_file.keys())
|
|
788
|
+
|
|
789
|
+
# Find which ones are missing (not provided via -e flags)
|
|
790
|
+
all_missing = all_required_for_warning - set(env_vars.keys() if env_vars else [])
|
|
791
|
+
|
|
792
|
+
if all_missing:
|
|
361
793
|
hud_console.warning(
|
|
362
|
-
f"
|
|
363
|
-
)
|
|
364
|
-
hud_console.info(
|
|
365
|
-
"These can be added to the lock file after build or provided with -e flags"
|
|
794
|
+
f"Environment variables not provided via -e flags: {', '.join(sorted(all_missing))}"
|
|
366
795
|
)
|
|
796
|
+
hud_console.info("These will be added to the required list in the lock file")
|
|
367
797
|
|
|
368
798
|
# Check for existing version and increment
|
|
369
799
|
lock_path = env_dir / "hud.lock.yaml"
|
|
@@ -378,17 +808,32 @@ def build_environment(
|
|
|
378
808
|
new_version = "0.1.0"
|
|
379
809
|
hud_console.info(f"Setting initial version: {new_version}")
|
|
380
810
|
|
|
381
|
-
#
|
|
811
|
+
# Determine base name for image references
|
|
812
|
+
if image_tag:
|
|
813
|
+
base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
|
|
814
|
+
|
|
815
|
+
# Collect runtime metadata and compute base image/platform
|
|
816
|
+
runtime_info = collect_runtime_metadata(temp_tag, verbose=verbose)
|
|
817
|
+
base_image = parse_base_image(dockerfile_path)
|
|
818
|
+
effective_platform = platform if platform is not None else "linux/amd64"
|
|
819
|
+
|
|
820
|
+
# Create lock file content with images subsection at top
|
|
382
821
|
lock_content = {
|
|
383
|
-
"version": "1.
|
|
384
|
-
"
|
|
822
|
+
"version": "1.3", # Lock file format version
|
|
823
|
+
"images": {
|
|
824
|
+
"local": f"{base_name}:{new_version}", # Local tag with version
|
|
825
|
+
"full": None, # Will be set with digest after build
|
|
826
|
+
"pushed": None, # Will be set by hud push
|
|
827
|
+
},
|
|
385
828
|
"build": {
|
|
386
|
-
"generatedAt": datetime.
|
|
829
|
+
"generatedAt": datetime.now(UTC).isoformat() + "Z",
|
|
387
830
|
"hudVersion": hud_version,
|
|
388
831
|
"directory": str(env_dir.name),
|
|
389
|
-
"version": new_version,
|
|
832
|
+
"version": new_version,
|
|
390
833
|
# Fast source fingerprint for change detection
|
|
391
834
|
"sourceHash": compute_source_hash(env_dir),
|
|
835
|
+
"baseImage": base_image,
|
|
836
|
+
"platform": effective_platform,
|
|
392
837
|
},
|
|
393
838
|
"environment": {
|
|
394
839
|
"initializeMs": analysis["initializeMs"],
|
|
@@ -396,8 +841,19 @@ def build_environment(
|
|
|
396
841
|
},
|
|
397
842
|
}
|
|
398
843
|
|
|
844
|
+
if runtime_info:
|
|
845
|
+
lock_content["environment"]["runtime"] = runtime_info
|
|
846
|
+
internal_count = int(analysis.get("internalToolCount", 0) or 0)
|
|
847
|
+
lock_content["environment"]["internalToolCount"] = internal_count
|
|
848
|
+
|
|
399
849
|
# Add environment variables section if any exist
|
|
400
|
-
|
|
850
|
+
# Include env vars from .env file as well
|
|
851
|
+
env_vars_from_file = set(env_from_file.keys()) if env_from_file else set()
|
|
852
|
+
|
|
853
|
+
# Check if we have any env vars to document
|
|
854
|
+
has_env_vars = bool(required_env or optional_env or env_vars or env_vars_from_file)
|
|
855
|
+
|
|
856
|
+
if has_env_vars:
|
|
401
857
|
lock_content["environment"]["variables"] = {}
|
|
402
858
|
|
|
403
859
|
# Add note about editing environment variables
|
|
@@ -406,23 +862,53 @@ def build_environment(
|
|
|
406
862
|
"Provided variables will be used when running the environment."
|
|
407
863
|
)
|
|
408
864
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
865
|
+
# Combine all required variables: from Dockerfile, .env file, and provided vars
|
|
866
|
+
all_required = set(required_env)
|
|
867
|
+
|
|
868
|
+
# Add all env vars from .env file to required
|
|
869
|
+
all_required.update(env_vars_from_file)
|
|
870
|
+
|
|
871
|
+
# Add all provided env vars to required
|
|
872
|
+
if env_vars:
|
|
873
|
+
all_required.update(env_vars.keys())
|
|
874
|
+
|
|
875
|
+
# Remove any that are optional - they stay in optional
|
|
876
|
+
all_required = all_required - set(optional_env)
|
|
877
|
+
|
|
878
|
+
if all_required:
|
|
879
|
+
lock_content["environment"]["variables"]["required"] = sorted(list(all_required))
|
|
413
880
|
if optional_env:
|
|
414
881
|
lock_content["environment"]["variables"]["optional"] = optional_env
|
|
415
882
|
|
|
416
883
|
# Add tools with full schemas for RL config generation
|
|
417
884
|
if analysis["tools"]:
|
|
418
|
-
|
|
419
|
-
|
|
885
|
+
tools_serialized: list[dict[str, Any]] = []
|
|
886
|
+
for tool in analysis["tools"]:
|
|
887
|
+
entry: dict[str, Any] = {
|
|
420
888
|
"name": tool["name"],
|
|
889
|
+
# Preserve legacy shape: always include description/inputSchema
|
|
421
890
|
"description": tool.get("description", ""),
|
|
422
891
|
"inputSchema": tool.get("inputSchema", {}),
|
|
423
892
|
}
|
|
424
|
-
|
|
425
|
-
|
|
893
|
+
if tool.get("internalTools"):
|
|
894
|
+
entry["internalTools"] = tool.get("internalTools")
|
|
895
|
+
tools_serialized.append(entry)
|
|
896
|
+
lock_content["tools"] = tools_serialized
|
|
897
|
+
|
|
898
|
+
# Add hub tools if present (analyze_environment returns hub_tools with snake_case)
|
|
899
|
+
hub_tools = analysis.get("hub_tools") or analysis.get("hubTools")
|
|
900
|
+
if hub_tools:
|
|
901
|
+
lock_content["hubTools"] = hub_tools
|
|
902
|
+
|
|
903
|
+
# Add prompts if present
|
|
904
|
+
prompts = analysis.get("prompts")
|
|
905
|
+
if prompts:
|
|
906
|
+
lock_content["prompts"] = prompts
|
|
907
|
+
|
|
908
|
+
# Add resources if present
|
|
909
|
+
resources = analysis.get("resources")
|
|
910
|
+
if resources:
|
|
911
|
+
lock_content["resources"] = resources
|
|
426
912
|
|
|
427
913
|
# Write lock file
|
|
428
914
|
lock_path = env_dir / "hud.lock.yaml"
|
|
@@ -450,15 +936,55 @@ def build_environment(
|
|
|
450
936
|
hud_console.progress_message("Rebuilding with lock file metadata...")
|
|
451
937
|
|
|
452
938
|
# Build final image with label (uses cache from first build)
|
|
453
|
-
#
|
|
454
|
-
base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
|
|
939
|
+
# Create tags: versioned and latest (and custom tag if provided)
|
|
455
940
|
version_tag = f"{base_name}:{new_version}"
|
|
941
|
+
latest_tag = f"{base_name}:latest"
|
|
942
|
+
|
|
943
|
+
# Build command - use buildx when remote cache is enabled
|
|
944
|
+
label_cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
|
|
945
|
+
|
|
946
|
+
# Specify dockerfile explicitly if not the default name
|
|
947
|
+
if dockerfile_path and dockerfile_path.name != "Dockerfile":
|
|
948
|
+
label_cmd.extend(["-f", str(dockerfile_path)])
|
|
456
949
|
|
|
457
|
-
label_cmd = ["docker", "build"]
|
|
458
950
|
# Use same defaulting for the second build step
|
|
459
951
|
label_platform = platform if platform is not None else "linux/amd64"
|
|
460
952
|
if label_platform:
|
|
461
953
|
label_cmd.extend(["--platform", label_platform])
|
|
954
|
+
|
|
955
|
+
# Add remote cache support for final build
|
|
956
|
+
if remote_cache:
|
|
957
|
+
try:
|
|
958
|
+
if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
|
|
959
|
+
hud_console.error(f"Invalid ECR repo name: {remote_cache}")
|
|
960
|
+
raise typer.Exit(1)
|
|
961
|
+
|
|
962
|
+
aws_account_id = os.getenv("AWS_ACCOUNT_ID")
|
|
963
|
+
aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
964
|
+
|
|
965
|
+
if not aws_account_id:
|
|
966
|
+
hud_console.error("AWS_ACCOUNT_ID environment variable not set")
|
|
967
|
+
raise typer.Exit(1)
|
|
968
|
+
|
|
969
|
+
cache_image = (
|
|
970
|
+
f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
label_cmd.extend(
|
|
974
|
+
[
|
|
975
|
+
"--cache-from",
|
|
976
|
+
f"type=registry,ref={cache_image}",
|
|
977
|
+
"--cache-to",
|
|
978
|
+
f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
|
|
979
|
+
"--load", # Load image to local Docker after build
|
|
980
|
+
]
|
|
981
|
+
)
|
|
982
|
+
except typer.Exit:
|
|
983
|
+
raise
|
|
984
|
+
except Exception as e:
|
|
985
|
+
hud_console.error(f"Remote cache setup error: {e}")
|
|
986
|
+
raise typer.Exit(1) from e
|
|
987
|
+
|
|
462
988
|
label_cmd.extend(
|
|
463
989
|
[
|
|
464
990
|
"--label",
|
|
@@ -466,12 +992,16 @@ def build_environment(
|
|
|
466
992
|
"--label",
|
|
467
993
|
f"org.hud.version={new_version}",
|
|
468
994
|
"-t",
|
|
469
|
-
|
|
995
|
+
version_tag, # Always tag with new version
|
|
470
996
|
"-t",
|
|
471
|
-
|
|
997
|
+
latest_tag, # Always tag with latest
|
|
472
998
|
]
|
|
473
999
|
)
|
|
474
1000
|
|
|
1001
|
+
# Add custom tag if user provided one
|
|
1002
|
+
if image_tag and image_tag not in [version_tag, latest_tag]:
|
|
1003
|
+
label_cmd.extend(["-t", image_tag])
|
|
1004
|
+
|
|
475
1005
|
label_cmd.append(str(env_dir))
|
|
476
1006
|
|
|
477
1007
|
# Run rebuild using Docker's native output formatting
|
|
@@ -479,34 +1009,40 @@ def build_environment(
|
|
|
479
1009
|
# Show Docker's native output when verbose
|
|
480
1010
|
result = subprocess.run(label_cmd, check=False) # noqa: S603
|
|
481
1011
|
else:
|
|
482
|
-
#
|
|
1012
|
+
# Capture output for error reporting, but don't show unless it fails
|
|
483
1013
|
result = subprocess.run( # noqa: S603
|
|
484
|
-
label_cmd,
|
|
1014
|
+
label_cmd, capture_output=True, text=True, check=False
|
|
485
1015
|
)
|
|
486
1016
|
|
|
487
1017
|
if result.returncode != 0:
|
|
488
1018
|
hud_console.error("Failed to rebuild with label")
|
|
1019
|
+
if not verbose and result.stderr:
|
|
1020
|
+
hud_console.info("Error output:")
|
|
1021
|
+
hud_console.info(str(result.stderr))
|
|
1022
|
+
if not verbose:
|
|
1023
|
+
hud_console.info("")
|
|
1024
|
+
hud_console.info("Run with --verbose to see full build output:")
|
|
1025
|
+
hud_console.command_example("hud build --verbose")
|
|
489
1026
|
raise typer.Exit(1)
|
|
490
1027
|
|
|
491
1028
|
hud_console.success("Built final image with lock file metadata")
|
|
492
1029
|
|
|
493
1030
|
# NOW get the image ID after the final build
|
|
494
|
-
image_id = get_docker_image_id(
|
|
1031
|
+
image_id = get_docker_image_id(version_tag)
|
|
495
1032
|
if image_id:
|
|
496
|
-
#
|
|
497
|
-
# Docker IDs come as sha256:hash, we want tag@sha256:hash
|
|
1033
|
+
# Store full reference with digest
|
|
498
1034
|
if image_id.startswith("sha256:"):
|
|
499
|
-
lock_content["
|
|
1035
|
+
lock_content["images"]["full"] = f"{version_tag}@{image_id}"
|
|
500
1036
|
else:
|
|
501
|
-
lock_content["
|
|
1037
|
+
lock_content["images"]["full"] = f"{version_tag}@sha256:{image_id}"
|
|
502
1038
|
|
|
503
|
-
# Update the lock file with the
|
|
1039
|
+
# Update the lock file with the full image reference
|
|
504
1040
|
with open(lock_path, "w") as f:
|
|
505
1041
|
yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
|
|
506
1042
|
|
|
507
|
-
hud_console.success("Updated lock file with image
|
|
1043
|
+
hud_console.success("Updated lock file with image digest")
|
|
508
1044
|
else:
|
|
509
|
-
hud_console.warning("Could not retrieve image
|
|
1045
|
+
hud_console.warning("Could not retrieve image digest")
|
|
510
1046
|
|
|
511
1047
|
# Remove temp image after we're done
|
|
512
1048
|
subprocess.run(["docker", "rmi", "-f", temp_tag], capture_output=True) # noqa: S603, S607
|
|
@@ -514,15 +1050,32 @@ def build_environment(
|
|
|
514
1050
|
# Add to local registry
|
|
515
1051
|
if image_id:
|
|
516
1052
|
# Save to local registry using the helper
|
|
517
|
-
|
|
1053
|
+
local_ref = lock_content.get("images", {}).get("local", version_tag)
|
|
1054
|
+
save_to_registry(lock_content, local_ref, verbose)
|
|
1055
|
+
|
|
1056
|
+
# Update tasks.json files with new version
|
|
1057
|
+
hud_console.progress_message("Updating task files with new version...")
|
|
1058
|
+
updated_task_files = update_tasks_json_versions(
|
|
1059
|
+
env_dir, base_name, existing_version, new_version
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
if updated_task_files:
|
|
1063
|
+
hud_console.success(f"Updated {len(updated_task_files)} task file(s)")
|
|
1064
|
+
else:
|
|
1065
|
+
hud_console.dim_info("No task files found or updated", value="")
|
|
518
1066
|
|
|
519
1067
|
# Print summary
|
|
520
1068
|
hud_console.section_title("Build Complete")
|
|
521
1069
|
|
|
522
1070
|
# Show the version tag as primary since that's what will be pushed
|
|
523
1071
|
hud_console.status_item("Built image", version_tag, primary=True)
|
|
524
|
-
|
|
525
|
-
|
|
1072
|
+
|
|
1073
|
+
# Show additional tags
|
|
1074
|
+
additional_tags = [latest_tag]
|
|
1075
|
+
if image_tag and image_tag not in [version_tag, latest_tag]:
|
|
1076
|
+
additional_tags.append(image_tag)
|
|
1077
|
+
hud_console.status_item("Also tagged", ", ".join(additional_tags))
|
|
1078
|
+
|
|
526
1079
|
hud_console.status_item("Version", new_version)
|
|
527
1080
|
hud_console.status_item("Lock file", "hud.lock.yaml")
|
|
528
1081
|
hud_console.status_item("Tools found", str(analysis["toolCount"]))
|
|
@@ -534,7 +1087,7 @@ def build_environment(
|
|
|
534
1087
|
hud_console.section_title("Next Steps")
|
|
535
1088
|
hud_console.info("Test locally:")
|
|
536
1089
|
hud_console.command_example("hud dev", "Hot-reload development")
|
|
537
|
-
hud_console.command_example(f"hud run {
|
|
1090
|
+
hud_console.command_example(f"hud run {version_tag}", "Run the built image")
|
|
538
1091
|
hud_console.info("")
|
|
539
1092
|
hud_console.info("Publish to registry:")
|
|
540
1093
|
hud_console.command_example("hud push", f"Push as {version_tag}")
|
|
@@ -552,6 +1105,7 @@ def build_command(
|
|
|
552
1105
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
|
553
1106
|
env_vars: dict[str, str] | None = None,
|
|
554
1107
|
platform: str | None = None,
|
|
1108
|
+
remote_cache: str | None = None,
|
|
555
1109
|
) -> None:
|
|
556
1110
|
"""Build a HUD environment and generate lock file."""
|
|
557
|
-
build_environment(directory, tag, no_cache, verbose, env_vars, platform)
|
|
1111
|
+
build_environment(directory, tag, no_cache, verbose, env_vars, platform, remote_cache)
|