hud-python 0.4.48__py3-none-any.whl → 0.4.50__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/agents/base.py +40 -34
- hud/agents/grounded_openai.py +1 -1
- hud/cli/__init__.py +78 -213
- hud/cli/build.py +105 -45
- hud/cli/dev.py +614 -743
- hud/cli/flows/tasks.py +98 -17
- hud/cli/init.py +18 -14
- hud/cli/push.py +27 -9
- hud/cli/rl/local_runner.py +3 -3
- hud/cli/tests/test_eval.py +168 -119
- hud/cli/tests/test_mcp_server.py +6 -95
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/source_hash.py +1 -1
- hud/server/__init__.py +2 -1
- hud/server/router.py +160 -0
- hud/server/server.py +246 -79
- hud/tools/base.py +9 -1
- hud/tools/bash.py +2 -2
- hud/tools/edit.py +3 -7
- hud/utils/hud_console.py +43 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.48.dist-info → hud_python-0.4.50.dist-info}/METADATA +1 -1
- {hud_python-0.4.48.dist-info → hud_python-0.4.50.dist-info}/RECORD +27 -26
- {hud_python-0.4.48.dist-info → hud_python-0.4.50.dist-info}/WHEEL +0 -0
- {hud_python-0.4.48.dist-info → hud_python-0.4.50.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.48.dist-info → hud_python-0.4.50.dist-info}/licenses/LICENSE +0 -0
hud/cli/flows/tasks.py
CHANGED
|
@@ -78,26 +78,38 @@ def _ensure_pushed(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, Any]:
|
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def _derive_remote_image(lock_data: dict[str, Any]) -> str:
|
|
81
|
-
"""Derive org/name:tag from lock file for MCP header.
|
|
81
|
+
"""Derive org/name:tag from lock file for remote MCP header.
|
|
82
82
|
|
|
83
|
-
Preference order:
|
|
84
|
-
1) lock_data["push"]["image_with_tag"]
|
|
85
|
-
2)
|
|
83
|
+
Preference order (new lock first, then legacy):
|
|
84
|
+
1) lock_data["push"]["image_with_tag"] (exact org/name:tag that was pushed)
|
|
85
|
+
2) lock_data["images"]["local"] (base name with internal version)
|
|
86
|
+
3) lock_data["image"] (legacy field; may contain tag or digest)
|
|
86
87
|
"""
|
|
87
|
-
|
|
88
|
+
if not isinstance(lock_data, dict): # Defensive
|
|
89
|
+
raise typer.Exit(1)
|
|
88
90
|
|
|
89
|
-
# 1)
|
|
90
|
-
|
|
91
|
+
# 1) Prefer the exact image that was pushed (org/name:tag)
|
|
92
|
+
push_info = lock_data.get("push") or {}
|
|
93
|
+
pushed_with_tag = str(push_info.get("image_with_tag") or "").strip()
|
|
91
94
|
if pushed_with_tag:
|
|
92
95
|
name, tag = extract_name_and_tag(pushed_with_tag)
|
|
93
96
|
return f"{name}:{tag}"
|
|
94
97
|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
# 2) Fall back to the local tag recorded in the new lock schema
|
|
99
|
+
images = lock_data.get("images") or {}
|
|
100
|
+
local_image = str(images.get("local") or "").strip()
|
|
101
|
+
if local_image:
|
|
102
|
+
name, tag = extract_name_and_tag(local_image)
|
|
103
|
+
return f"{name}:{tag}"
|
|
104
|
+
|
|
105
|
+
# 3) Legacy top-level image field
|
|
106
|
+
legacy_image = str(lock_data.get("image") or "").strip()
|
|
107
|
+
if legacy_image:
|
|
108
|
+
name, tag = extract_name_and_tag(legacy_image)
|
|
109
|
+
return f"{name}:{tag}"
|
|
110
|
+
|
|
111
|
+
# If none of the above exist, we cannot derive an image
|
|
112
|
+
raise typer.Exit(1)
|
|
101
113
|
|
|
102
114
|
|
|
103
115
|
def _extract_existing_images(tasks: list[Task]) -> set[str]:
|
|
@@ -183,6 +195,63 @@ def _extract_dotenv_api_key_vars(env_dir: Path) -> set[str]:
|
|
|
183
195
|
return detected
|
|
184
196
|
|
|
185
197
|
|
|
198
|
+
def _extract_env_vars_from_docker_args(args: list[str]) -> set[str]:
|
|
199
|
+
"""Extract environment variable names from docker run arguments.
|
|
200
|
+
|
|
201
|
+
Parses args like: ["run", "--rm", "-i", "-e", "API_KEY=value", "-e", "TOKEN", "image:tag"]
|
|
202
|
+
Returns set of env var names (not values).
|
|
203
|
+
"""
|
|
204
|
+
env_vars: set[str] = set()
|
|
205
|
+
i = 0
|
|
206
|
+
while i < len(args):
|
|
207
|
+
arg = args[i]
|
|
208
|
+
|
|
209
|
+
# Check for -e or --env flags
|
|
210
|
+
if arg in ("-e", "--env"):
|
|
211
|
+
if i + 1 < len(args):
|
|
212
|
+
env_spec = args[i + 1]
|
|
213
|
+
# Could be "KEY=value" or just "KEY"
|
|
214
|
+
var_name = env_spec.split("=", 1)[0].strip()
|
|
215
|
+
if var_name:
|
|
216
|
+
env_vars.add(var_name)
|
|
217
|
+
i += 2
|
|
218
|
+
continue
|
|
219
|
+
# Check for --env=KEY=value format
|
|
220
|
+
elif arg.startswith("--env="):
|
|
221
|
+
env_spec = arg[6:] # Remove "--env=" prefix
|
|
222
|
+
var_name = env_spec.split("=", 1)[0].strip()
|
|
223
|
+
if var_name:
|
|
224
|
+
env_vars.add(var_name)
|
|
225
|
+
|
|
226
|
+
i += 1
|
|
227
|
+
|
|
228
|
+
env_vars.discard("HUD_API_KEY")
|
|
229
|
+
return env_vars
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _extract_vars_from_task_configs(raw_tasks: list[dict[str, Any]]) -> set[str]:
|
|
233
|
+
"""Extract environment variable names from docker run commands in task mcp_configs."""
|
|
234
|
+
all_env_vars: set[str] = set()
|
|
235
|
+
|
|
236
|
+
for task in raw_tasks:
|
|
237
|
+
mcp_config = task.get("mcp_config", {})
|
|
238
|
+
|
|
239
|
+
# Iterate through all server configs
|
|
240
|
+
for server_config in mcp_config.values():
|
|
241
|
+
if not isinstance(server_config, dict):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
command = server_config.get("command", "")
|
|
245
|
+
args = server_config.get("args", [])
|
|
246
|
+
|
|
247
|
+
# Only process docker run commands
|
|
248
|
+
if command == "docker" and "run" in args:
|
|
249
|
+
env_vars = _extract_env_vars_from_docker_args(args)
|
|
250
|
+
all_env_vars.update(env_vars)
|
|
251
|
+
|
|
252
|
+
return all_env_vars
|
|
253
|
+
|
|
254
|
+
|
|
186
255
|
def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
187
256
|
"""Convert a local tasks file to remote MCP tasks and return new filename.
|
|
188
257
|
|
|
@@ -297,12 +366,21 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
|
297
366
|
hud_console.success(f"Updated {tasks_path.name} with latest image: {remote_image}")
|
|
298
367
|
return str(tasks_path)
|
|
299
368
|
|
|
300
|
-
# Extract
|
|
369
|
+
# Extract environment variables from multiple sources:
|
|
370
|
+
# 1. Lock file (authoritative for required env vars)
|
|
301
371
|
provided_keys = _extract_api_key_vars(lock_data)
|
|
372
|
+
|
|
373
|
+
# 2. Task configs (docker run -e flags)
|
|
374
|
+
task_env_vars = _extract_vars_from_task_configs(raw_tasks)
|
|
375
|
+
|
|
376
|
+
# 3. .env file (detect API-like vars)
|
|
302
377
|
dotenv_keys = _extract_dotenv_api_key_vars(env_dir)
|
|
303
378
|
|
|
304
|
-
#
|
|
305
|
-
|
|
379
|
+
# Combine: lock file vars + task config vars, then check for missing from .env
|
|
380
|
+
all_detected = provided_keys | task_env_vars
|
|
381
|
+
|
|
382
|
+
# If .env contains API-like vars not yet included, offer to add them
|
|
383
|
+
missing = sorted(dotenv_keys - all_detected)
|
|
306
384
|
if missing:
|
|
307
385
|
names_preview = ", ".join(missing)
|
|
308
386
|
prompt = (
|
|
@@ -310,7 +388,10 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
|
310
388
|
"Include them as remote headers (values will be ${VAR} placeholders)?"
|
|
311
389
|
)
|
|
312
390
|
if hud_console.confirm(prompt, default=True):
|
|
313
|
-
|
|
391
|
+
all_detected.update(missing)
|
|
392
|
+
|
|
393
|
+
# Final set of env vars to convert to headers
|
|
394
|
+
provided_keys = all_detected
|
|
314
395
|
|
|
315
396
|
extra_api_key_headers: dict[str, str] = {}
|
|
316
397
|
for var_name in provided_keys:
|
hud/cli/init.py
CHANGED
|
@@ -29,9 +29,12 @@ SKIP_DIR_NAMES = {"node_modules", "__pycache__", "dist", "build", ".next", ".git
|
|
|
29
29
|
|
|
30
30
|
# Files that need placeholder replacement
|
|
31
31
|
PLACEHOLDER_FILES = {
|
|
32
|
-
"pyproject.toml",
|
|
32
|
+
"server/pyproject.toml",
|
|
33
|
+
"environment/pyproject.toml",
|
|
34
|
+
"server/main.py",
|
|
35
|
+
"server/README.md",
|
|
36
|
+
"environment/README.md",
|
|
33
37
|
"tasks.json",
|
|
34
|
-
"src/controller/server.py",
|
|
35
38
|
"test_env.ipynb",
|
|
36
39
|
"README.md",
|
|
37
40
|
}
|
|
@@ -48,7 +51,7 @@ def _replace_placeholders(target_dir: Path, env_name: str) -> list[str]:
|
|
|
48
51
|
List of files that were modified
|
|
49
52
|
"""
|
|
50
53
|
modified_files = []
|
|
51
|
-
placeholder = "
|
|
54
|
+
placeholder = "blank" # Placeholder used in blank environment template
|
|
52
55
|
|
|
53
56
|
# Normalize environment name for use in code/configs
|
|
54
57
|
# Replace spaces and special chars with underscores for Python identifiers
|
|
@@ -240,17 +243,18 @@ def create_environment(
|
|
|
240
243
|
f"Downloaded {len(files_created_dl)} files in {duration_ms} ms into {target_dir}"
|
|
241
244
|
)
|
|
242
245
|
|
|
243
|
-
# Replace placeholders in template files
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
246
|
+
# Replace placeholders in template files (only for blank preset)
|
|
247
|
+
if preset_normalized == "blank":
|
|
248
|
+
hud_console.section_title("Customizing template files")
|
|
249
|
+
modified_files = _replace_placeholders(target_dir, name)
|
|
250
|
+
if modified_files:
|
|
251
|
+
hud_console.success(f"Replaced placeholders in {len(modified_files)} files:")
|
|
252
|
+
for file in modified_files[:5]: # Show first 5 files
|
|
253
|
+
hud_console.status_item(file, "updated")
|
|
254
|
+
if len(modified_files) > 5:
|
|
255
|
+
hud_console.info(f"... and {len(modified_files) - 5} more files")
|
|
256
|
+
else:
|
|
257
|
+
hud_console.info("No placeholder replacements needed")
|
|
254
258
|
|
|
255
259
|
hud_console.section_title("Top-level files and folders")
|
|
256
260
|
for entry in sorted(os.listdir(target_dir)):
|
hud/cli/push.py
CHANGED
|
@@ -163,10 +163,7 @@ def push_environment(
|
|
|
163
163
|
lock_data = yaml.safe_load(f)
|
|
164
164
|
|
|
165
165
|
# Handle both old and new lock file formats
|
|
166
|
-
local_image = lock_data.get("image", "")
|
|
167
|
-
if not local_image and "build" in lock_data:
|
|
168
|
-
# New format might have image elsewhere
|
|
169
|
-
local_image = lock_data.get("image", "")
|
|
166
|
+
local_image = lock_data.get("images", {}).get("local") or lock_data.get("image", "")
|
|
170
167
|
|
|
171
168
|
# Get internal version from lock file
|
|
172
169
|
internal_version = lock_data.get("build", {}).get("version", None)
|
|
@@ -293,7 +290,7 @@ def push_environment(
|
|
|
293
290
|
# Push the image
|
|
294
291
|
hud_console.progress_message(f"Pushing {image} to registry...")
|
|
295
292
|
|
|
296
|
-
# Show push output
|
|
293
|
+
# Show push output (filtered for cleaner display)
|
|
297
294
|
process = subprocess.Popen( # noqa: S603
|
|
298
295
|
["docker", "push", image], # noqa: S607
|
|
299
296
|
stdout=subprocess.PIPE,
|
|
@@ -303,8 +300,27 @@ def push_environment(
|
|
|
303
300
|
errors="replace",
|
|
304
301
|
)
|
|
305
302
|
|
|
303
|
+
# Filter output to only show meaningful progress
|
|
304
|
+
layers_pushed = 0
|
|
306
305
|
for line in process.stdout or []:
|
|
307
|
-
|
|
306
|
+
line = line.rstrip()
|
|
307
|
+
# Only show: digest, pushed, mounted, or error lines
|
|
308
|
+
if any(
|
|
309
|
+
keyword in line.lower()
|
|
310
|
+
for keyword in ["digest:", "pushed", "mounted", "error", "denied"]
|
|
311
|
+
):
|
|
312
|
+
if "pushed" in line.lower():
|
|
313
|
+
layers_pushed += 1
|
|
314
|
+
if (
|
|
315
|
+
verbose
|
|
316
|
+
or "error" in line.lower()
|
|
317
|
+
or "denied" in line.lower()
|
|
318
|
+
or "digest:" in line.lower()
|
|
319
|
+
):
|
|
320
|
+
hud_console.info(line)
|
|
321
|
+
|
|
322
|
+
if layers_pushed > 0 and not verbose:
|
|
323
|
+
hud_console.info(f"Pushed {layers_pushed} layer(s)")
|
|
308
324
|
|
|
309
325
|
process.wait()
|
|
310
326
|
|
|
@@ -331,8 +347,10 @@ def push_environment(
|
|
|
331
347
|
hud_console.section_title("Pushed Image")
|
|
332
348
|
hud_console.status_item("Registry", pushed_digest, primary=True)
|
|
333
349
|
|
|
334
|
-
# Update the lock file with
|
|
335
|
-
|
|
350
|
+
# Update the lock file with pushed image reference
|
|
351
|
+
if "images" not in lock_data:
|
|
352
|
+
lock_data["images"] = {}
|
|
353
|
+
lock_data["images"]["pushed"] = image
|
|
336
354
|
|
|
337
355
|
# Add push information
|
|
338
356
|
from datetime import UTC, datetime
|
|
@@ -348,7 +366,7 @@ def push_environment(
|
|
|
348
366
|
with open(lock_path, "w") as f:
|
|
349
367
|
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
350
368
|
|
|
351
|
-
hud_console.success("Updated lock file with
|
|
369
|
+
hud_console.success("Updated lock file with pushed image reference")
|
|
352
370
|
|
|
353
371
|
# Upload lock file to HUD registry
|
|
354
372
|
try:
|
hud/cli/rl/local_runner.py
CHANGED
|
@@ -190,9 +190,9 @@ def run_local_training(
|
|
|
190
190
|
|
|
191
191
|
invalid_tasks: list[str] = []
|
|
192
192
|
for i, task in enumerate(tasks):
|
|
193
|
-
if not hasattr(task, "prompt") or not task.prompt:
|
|
193
|
+
if not hasattr(task, "prompt") or not task.prompt: # type: ignore
|
|
194
194
|
invalid_tasks.append(f"Task {i}: missing 'prompt' field")
|
|
195
|
-
if not hasattr(task, "mcp_config") or not task.mcp_config:
|
|
195
|
+
if not hasattr(task, "mcp_config") or not task.mcp_config: # type: ignore
|
|
196
196
|
invalid_tasks.append(f"Task {i}: missing 'mcp_config' field")
|
|
197
197
|
|
|
198
198
|
if invalid_tasks:
|
|
@@ -530,7 +530,7 @@ def run_local_training(
|
|
|
530
530
|
# Import and run the async training function lazily
|
|
531
531
|
from hud.rl.train import train # heavy import
|
|
532
532
|
|
|
533
|
-
asyncio.run(train(config, tasks))
|
|
533
|
+
asyncio.run(train(config, tasks)) # type: ignore
|
|
534
534
|
console.print("\n[green]✅ Training completed successfully![/green]")
|
|
535
535
|
|
|
536
536
|
try:
|