hud-python 0.4.8__py3-none-any.whl → 0.4.9__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 +50 -1
- hud/cli/__init__.py +120 -1
- hud/cli/analyze_metadata.py +29 -41
- hud/cli/build.py +7 -0
- hud/cli/debug.py +8 -1
- hud/cli/eval.py +226 -0
- hud/cli/list_func.py +212 -0
- hud/cli/pull.py +4 -13
- hud/cli/push.py +84 -41
- hud/cli/registry.py +155 -0
- hud/cli/remove.py +200 -0
- hud/cli/tests/test_analyze_metadata.py +277 -0
- hud/cli/tests/test_build.py +450 -0
- hud/cli/tests/test_list_func.py +288 -0
- hud/cli/tests/test_pull.py +400 -0
- hud/cli/tests/test_push.py +379 -0
- hud/cli/tests/test_registry.py +264 -0
- hud/clients/base.py +13 -1
- hud/tools/__init__.py +2 -0
- hud/tools/response.py +54 -0
- hud/utils/design.py +10 -0
- hud/utils/mcp.py +14 -2
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.8.dist-info → hud_python-0.4.9.dist-info}/METADATA +12 -1
- {hud_python-0.4.8.dist-info → hud_python-0.4.9.dist-info}/RECORD +29 -18
- {hud_python-0.4.8.dist-info → hud_python-0.4.9.dist-info}/WHEEL +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.9.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.9.dist-info}/licenses/LICENSE +0 -0
hud/cli/list_func.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""List HUD environments from local registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from hud.utils.design import HUDDesign
|
|
14
|
+
|
|
15
|
+
from .registry import get_registry_dir, list_registry_entries, extract_name_and_tag
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_timestamp(timestamp: float | None) -> str:
|
|
19
|
+
"""Format timestamp to human-readable relative time."""
|
|
20
|
+
if not timestamp:
|
|
21
|
+
return "unknown"
|
|
22
|
+
|
|
23
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
24
|
+
now = datetime.now()
|
|
25
|
+
delta = now - dt
|
|
26
|
+
|
|
27
|
+
if delta.days > 365:
|
|
28
|
+
return f"{delta.days // 365}y ago"
|
|
29
|
+
elif delta.days > 30:
|
|
30
|
+
return f"{delta.days // 30}mo ago"
|
|
31
|
+
elif delta.days > 0:
|
|
32
|
+
return f"{delta.days}d ago"
|
|
33
|
+
elif delta.seconds > 3600:
|
|
34
|
+
return f"{delta.seconds // 3600}h ago"
|
|
35
|
+
elif delta.seconds > 60:
|
|
36
|
+
return f"{delta.seconds // 60}m ago"
|
|
37
|
+
else:
|
|
38
|
+
return "just now"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def list_environments(
|
|
42
|
+
filter_name: str | None = None,
|
|
43
|
+
json_output: bool = False,
|
|
44
|
+
show_all: bool = False,
|
|
45
|
+
verbose: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""List all HUD environments in the local registry."""
|
|
48
|
+
design = HUDDesign()
|
|
49
|
+
|
|
50
|
+
if not json_output:
|
|
51
|
+
design.header("HUD Environment Registry")
|
|
52
|
+
|
|
53
|
+
# Check for environment directory
|
|
54
|
+
env_dir = get_registry_dir()
|
|
55
|
+
if not env_dir.exists():
|
|
56
|
+
if json_output:
|
|
57
|
+
print("[]")
|
|
58
|
+
else:
|
|
59
|
+
design.info("No environments found in local registry.")
|
|
60
|
+
design.info("")
|
|
61
|
+
design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
62
|
+
design.info("Build environments with: [cyan]hud build[/cyan]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Collect all environments using the registry helper
|
|
66
|
+
environments = []
|
|
67
|
+
|
|
68
|
+
for digest, lock_file in list_registry_entries():
|
|
69
|
+
try:
|
|
70
|
+
# Read lock file
|
|
71
|
+
with open(lock_file) as f:
|
|
72
|
+
lock_data = yaml.safe_load(f)
|
|
73
|
+
|
|
74
|
+
# Extract metadata
|
|
75
|
+
image = lock_data.get("image", "unknown")
|
|
76
|
+
name, tag = extract_name_and_tag(image)
|
|
77
|
+
|
|
78
|
+
# Apply filter if specified
|
|
79
|
+
if filter_name and filter_name.lower() not in name.lower():
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Get additional metadata
|
|
83
|
+
metadata = lock_data.get("metadata", {})
|
|
84
|
+
description = metadata.get("description", "")
|
|
85
|
+
tools_count = len(metadata.get("tools", []))
|
|
86
|
+
|
|
87
|
+
# Get file modification time as pulled time
|
|
88
|
+
pulled_time = lock_file.stat().st_mtime
|
|
89
|
+
|
|
90
|
+
environments.append({
|
|
91
|
+
"name": name,
|
|
92
|
+
"tag": tag,
|
|
93
|
+
"digest": digest,
|
|
94
|
+
"description": description,
|
|
95
|
+
"tools_count": tools_count,
|
|
96
|
+
"pulled_time": pulled_time,
|
|
97
|
+
"image": image,
|
|
98
|
+
"path": str(lock_file),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
if verbose:
|
|
103
|
+
design.warning(f"Failed to read {lock_file}: {e}")
|
|
104
|
+
|
|
105
|
+
# Sort by pulled time (newest first)
|
|
106
|
+
environments.sort(key=lambda x: x["pulled_time"], reverse=True)
|
|
107
|
+
|
|
108
|
+
if json_output:
|
|
109
|
+
# Output as JSON
|
|
110
|
+
import json
|
|
111
|
+
json_data = []
|
|
112
|
+
for env in environments:
|
|
113
|
+
json_data.append({
|
|
114
|
+
"name": env["name"],
|
|
115
|
+
"tag": env["tag"],
|
|
116
|
+
"digest": env["digest"],
|
|
117
|
+
"description": env["description"],
|
|
118
|
+
"tools_count": env["tools_count"],
|
|
119
|
+
"pulled_time": env["pulled_time"],
|
|
120
|
+
"image": env["image"],
|
|
121
|
+
"path": env["path"],
|
|
122
|
+
})
|
|
123
|
+
print(json.dumps(json_data, indent=2))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if not environments:
|
|
127
|
+
design.info("No environments found matching criteria.")
|
|
128
|
+
design.info("")
|
|
129
|
+
design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
130
|
+
design.info("Build environments with: [cyan]hud build[/cyan]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Create table
|
|
134
|
+
table = Table(title=f"Found {len(environments)} environment{'s' if len(environments) != 1 else ''}")
|
|
135
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
136
|
+
table.add_column("Tag", style="green")
|
|
137
|
+
table.add_column("Description", style="white")
|
|
138
|
+
table.add_column("Tools", justify="right", style="yellow")
|
|
139
|
+
table.add_column("Pulled", style="dim")
|
|
140
|
+
|
|
141
|
+
if show_all or verbose:
|
|
142
|
+
table.add_column("Digest", style="dim")
|
|
143
|
+
|
|
144
|
+
# Add rows
|
|
145
|
+
for env in environments:
|
|
146
|
+
# Truncate description if too long
|
|
147
|
+
desc = env["description"]
|
|
148
|
+
if desc and len(desc) > 50 and not verbose:
|
|
149
|
+
desc = desc[:47] + "..."
|
|
150
|
+
|
|
151
|
+
row = [
|
|
152
|
+
env["name"],
|
|
153
|
+
env["tag"],
|
|
154
|
+
desc or "[dim]No description[/dim]",
|
|
155
|
+
str(env["tools_count"]),
|
|
156
|
+
format_timestamp(env["pulled_time"]),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
if show_all or verbose:
|
|
160
|
+
row.append(env["digest"][:12])
|
|
161
|
+
|
|
162
|
+
table.add_row(*row)
|
|
163
|
+
|
|
164
|
+
design.print(table)
|
|
165
|
+
design.info("")
|
|
166
|
+
|
|
167
|
+
# Show usage hints
|
|
168
|
+
design.section_title("Usage")
|
|
169
|
+
if environments:
|
|
170
|
+
# Use the most recently pulled environment as example
|
|
171
|
+
example_env = environments[0]
|
|
172
|
+
example_ref = f"{example_env['name']}:{example_env['tag']}"
|
|
173
|
+
|
|
174
|
+
design.info(f"Run an environment: [cyan]hud run {example_ref}[/cyan]")
|
|
175
|
+
design.info(f"Analyze tools: [cyan]hud analyze {example_ref}[/cyan]")
|
|
176
|
+
design.info(f"Debug server: [cyan]hud debug {example_ref}[/cyan]")
|
|
177
|
+
|
|
178
|
+
design.info("Pull more environments: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
179
|
+
design.info("Build new environments: [cyan]hud build[/cyan]")
|
|
180
|
+
|
|
181
|
+
if verbose:
|
|
182
|
+
design.info("")
|
|
183
|
+
design.info(f"[dim]Registry location: {env_dir}[/dim]")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def list_command(
|
|
187
|
+
filter_name: str | None = typer.Option(
|
|
188
|
+
None, "--filter", "-f", help="Filter environments by name (case-insensitive)"
|
|
189
|
+
),
|
|
190
|
+
json_output: bool = typer.Option(
|
|
191
|
+
False, "--json", help="Output as JSON"
|
|
192
|
+
),
|
|
193
|
+
show_all: bool = typer.Option(
|
|
194
|
+
False, "--all", "-a", help="Show all columns including digest"
|
|
195
|
+
),
|
|
196
|
+
verbose: bool = typer.Option(
|
|
197
|
+
False, "--verbose", "-v", help="Show detailed output"
|
|
198
|
+
),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""📋 List all HUD environments in local registry.
|
|
201
|
+
|
|
202
|
+
Shows environments pulled with 'hud pull' or built with 'hud build',
|
|
203
|
+
stored in ~/.hud/envs/
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
hud list # List all environments
|
|
207
|
+
hud list --filter text # Filter by name
|
|
208
|
+
hud list --json # Output as JSON
|
|
209
|
+
hud list --all # Show digest column
|
|
210
|
+
hud list --verbose # Show full descriptions
|
|
211
|
+
"""
|
|
212
|
+
list_environments(filter_name, json_output, show_all, verbose)
|
hud/cli/pull.py
CHANGED
|
@@ -14,6 +14,8 @@ from rich.table import Table
|
|
|
14
14
|
from hud.settings import settings
|
|
15
15
|
from hud.utils.design import HUDDesign
|
|
16
16
|
|
|
17
|
+
from .registry import save_to_registry
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def get_docker_manifest(image: str) -> dict | None:
|
|
19
21
|
"""Get manifest from Docker registry without pulling the image."""
|
|
@@ -282,19 +284,8 @@ def pull_environment(
|
|
|
282
284
|
|
|
283
285
|
# Store lock file locally if we have full lock data (not minimal manifest data)
|
|
284
286
|
if lock_data and lock_data.get("source") != "docker-manifest":
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
# Store under ~/.hud/envs/<digest>/
|
|
289
|
-
local_env_dir = Path.home() / ".hud" / "envs" / digest
|
|
290
|
-
local_env_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
-
|
|
292
|
-
local_lock_path = local_env_dir / "hud.lock.yaml"
|
|
293
|
-
with open(local_lock_path, "w") as f:
|
|
294
|
-
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
295
|
-
|
|
296
|
-
if verbose:
|
|
297
|
-
design.info(f"Stored lock file: {local_lock_path}")
|
|
287
|
+
# Save to local registry using the helper
|
|
288
|
+
save_to_registry(lock_data, image_ref, verbose)
|
|
298
289
|
|
|
299
290
|
# Success!
|
|
300
291
|
design.success("Pull complete!")
|
hud/cli/push.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import subprocess
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote
|
|
8
9
|
|
|
9
10
|
import requests
|
|
10
11
|
import typer
|
|
@@ -14,6 +15,12 @@ from hud.settings import settings
|
|
|
14
15
|
from hud.utils.design import HUDDesign
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def _get_response_text(response: requests.Response) -> str:
|
|
19
|
+
try:
|
|
20
|
+
return response.json().get("detail", "No detail available")
|
|
21
|
+
except Exception:
|
|
22
|
+
return response.text
|
|
23
|
+
|
|
17
24
|
def get_docker_username() -> str | None:
|
|
18
25
|
"""Get the current Docker username if logged in."""
|
|
19
26
|
try:
|
|
@@ -312,11 +319,11 @@ def push_environment(
|
|
|
312
319
|
lock_data["image"] = pushed_digest
|
|
313
320
|
|
|
314
321
|
# Add push information
|
|
315
|
-
from datetime import datetime
|
|
322
|
+
from datetime import datetime, UTC
|
|
316
323
|
|
|
317
324
|
lock_data["push"] = {
|
|
318
325
|
"source": local_image,
|
|
319
|
-
"pushedAt": datetime.
|
|
326
|
+
"pushedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
320
327
|
"registry": pushed_digest.split("/")[0] if "/" in pushed_digest else "docker.io",
|
|
321
328
|
}
|
|
322
329
|
|
|
@@ -331,52 +338,88 @@ def push_environment(
|
|
|
331
338
|
# Extract org/name:tag from the pushed image
|
|
332
339
|
# e.g., "docker.io/hudpython/test_init:latest@sha256:..." -> "hudpython/test_init:latest"
|
|
333
340
|
# e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
|
|
334
|
-
|
|
341
|
+
# Use the original image name for the registry path, not the digest
|
|
342
|
+
# The digest might not contain the tag information
|
|
343
|
+
registry_image = image # This is the image we tagged and pushed (e.g., hudpython/hud-text-2048:0.1.2)
|
|
344
|
+
|
|
345
|
+
# Remove any registry prefix for the HUD registry path
|
|
346
|
+
registry_parts = registry_image.split("/")
|
|
335
347
|
if len(registry_parts) >= 2:
|
|
336
348
|
# Handle docker.io/org/name or just org/name
|
|
337
|
-
if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io"]:
|
|
338
|
-
# Remove registry prefix
|
|
339
|
-
name_with_tag = "/".join(registry_parts[1:])
|
|
349
|
+
if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io", "ghcr.io"]:
|
|
350
|
+
# Remove registry prefix
|
|
351
|
+
name_with_tag = "/".join(registry_parts[1:])
|
|
352
|
+
elif "." in registry_parts[0] or ":" in registry_parts[0]:
|
|
353
|
+
# Likely a registry URL (has dots or port)
|
|
354
|
+
name_with_tag = "/".join(registry_parts[1:])
|
|
340
355
|
else:
|
|
341
|
-
#
|
|
342
|
-
name_with_tag =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if ":" not in name_with_tag:
|
|
346
|
-
name_with_tag = f"{name_with_tag}:latest"
|
|
347
|
-
|
|
348
|
-
# Upload to HUD registry
|
|
349
|
-
design.progress_message("Uploading metadata to HUD registry...")
|
|
350
|
-
|
|
351
|
-
registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{name_with_tag}"
|
|
352
|
-
|
|
353
|
-
# Prepare the payload
|
|
354
|
-
payload = {
|
|
355
|
-
"lock": yaml.dump(lock_data, default_flow_style=False, sort_keys=False),
|
|
356
|
-
"digest": pushed_digest.split("@")[-1] if "@" in pushed_digest else "latest",
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
headers = {"Authorization": f"Bearer {settings.api_key}"}
|
|
360
|
-
|
|
361
|
-
response = requests.post(registry_url, json=payload, headers=headers, timeout=10)
|
|
356
|
+
# No registry prefix, use as-is
|
|
357
|
+
name_with_tag = registry_image
|
|
358
|
+
else:
|
|
359
|
+
name_with_tag = registry_image
|
|
362
360
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
361
|
+
# The image variable already has the tag, no need to add :latest
|
|
362
|
+
|
|
363
|
+
# Validate the image format
|
|
364
|
+
if not name_with_tag:
|
|
365
|
+
design.warning("Could not determine image name for registry upload")
|
|
366
|
+
raise typer.Exit(0)
|
|
367
|
+
|
|
368
|
+
# For HUD registry, we need org/name format
|
|
369
|
+
if "/" not in name_with_tag:
|
|
370
|
+
design.warning("Image name must include organization/namespace for HUD registry")
|
|
371
|
+
design.info(f"Current format: {name_with_tag}")
|
|
372
|
+
design.info("Expected format: org/name:tag (e.g., hudpython/myenv:v1.0)")
|
|
373
|
+
design.info("\nYour Docker push succeeded - share hud.lock.yaml manually")
|
|
374
|
+
raise typer.Exit(0)
|
|
375
|
+
|
|
376
|
+
# Upload to HUD registry
|
|
377
|
+
design.progress_message("Uploading metadata to HUD registry...")
|
|
378
|
+
|
|
379
|
+
# URL-encode the path segments to handle special characters in tags
|
|
380
|
+
url_safe_path = "/".join(quote(part, safe="") for part in name_with_tag.split("/"))
|
|
381
|
+
registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{url_safe_path}"
|
|
382
|
+
|
|
383
|
+
# Prepare the payload
|
|
384
|
+
payload = {
|
|
385
|
+
"lock": yaml.dump(lock_data, default_flow_style=False, sort_keys=False),
|
|
386
|
+
"digest": pushed_digest.split("@")[-1] if "@" in pushed_digest else None,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
headers = {"Authorization": f"Bearer {settings.api_key}"}
|
|
390
|
+
|
|
391
|
+
response = requests.post(registry_url, json=payload, headers=headers, timeout=10)
|
|
392
|
+
|
|
393
|
+
if response.status_code in [200, 201]:
|
|
394
|
+
design.success("Metadata uploaded to HUD registry")
|
|
395
|
+
design.info("Others can now pull with:")
|
|
396
|
+
design.command_example(f"hud pull {name_with_tag}")
|
|
397
|
+
design.info("")
|
|
398
|
+
elif response.status_code == 401:
|
|
399
|
+
design.error("Authentication failed")
|
|
400
|
+
design.info("Check your HUD_API_KEY is valid")
|
|
401
|
+
design.info("Get a new key at: https://hud.so/settings")
|
|
402
|
+
elif response.status_code == 403:
|
|
403
|
+
design.error("Permission denied")
|
|
404
|
+
design.info("You may not have access to push to this namespace")
|
|
405
|
+
elif response.status_code == 409:
|
|
406
|
+
design.warning("This version already exists in the registry")
|
|
407
|
+
design.info("Consider using a different tag if you want to update")
|
|
373
408
|
else:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
design.info("Share hud.lock.yaml
|
|
409
|
+
design.warning(f"Could not upload to registry: {response.status_code}")
|
|
410
|
+
design.warning(_get_response_text(response))
|
|
411
|
+
design.info("Share hud.lock.yaml manually\n")
|
|
412
|
+
except requests.exceptions.Timeout:
|
|
413
|
+
design.warning("Registry upload timed out")
|
|
414
|
+
design.info("The registry might be slow or unavailable")
|
|
415
|
+
design.info("Your Docker push succeeded - share hud.lock.yaml manually")
|
|
416
|
+
except requests.exceptions.ConnectionError:
|
|
417
|
+
design.warning("Could not connect to HUD registry")
|
|
418
|
+
design.info("Check your internet connection")
|
|
419
|
+
design.info("Your Docker push succeeded - share hud.lock.yaml manually")
|
|
377
420
|
except Exception as e:
|
|
378
421
|
design.warning(f"Registry upload failed: {e}")
|
|
379
|
-
design.info("Share hud.lock.yaml manually
|
|
422
|
+
design.info("Share hud.lock.yaml manually")
|
|
380
423
|
|
|
381
424
|
# Show usage examples
|
|
382
425
|
design.section_title("What's Next?")
|
hud/cli/registry.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Local registry management for HUD environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional, Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from hud.utils.design import HUDDesign
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_registry_dir() -> Path:
|
|
14
|
+
"""Get the base directory for the local HUD registry."""
|
|
15
|
+
return Path.home() / ".hud" / "envs"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_digest_from_image(image_ref: str) -> str:
|
|
19
|
+
"""Extract a digest identifier from a Docker image reference.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
image_ref: Docker image reference (e.g., "image:tag@sha256:abc123...")
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Digest string for use as directory name (max 12 chars)
|
|
26
|
+
"""
|
|
27
|
+
if "@sha256:" in image_ref:
|
|
28
|
+
# Extract from digest reference: image@sha256:abc123...
|
|
29
|
+
return image_ref.split("@sha256:")[-1][:12]
|
|
30
|
+
elif image_ref.startswith("sha256:"):
|
|
31
|
+
# Direct digest format
|
|
32
|
+
return image_ref.split(":")[-1][:12]
|
|
33
|
+
elif ":" in image_ref and "/" not in image_ref.split(":")[-1]:
|
|
34
|
+
# Has a tag but no slashes after colon (not a port)
|
|
35
|
+
tag = image_ref.split(":")[-1]
|
|
36
|
+
return tag[:12] if tag else "latest"
|
|
37
|
+
else:
|
|
38
|
+
# No tag specified or complex format
|
|
39
|
+
return "latest"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_name_and_tag(image_ref: str) -> tuple[str, str]:
|
|
43
|
+
"""Extract organization/name and tag from Docker image reference.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
image_ref: Docker image reference
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Tuple of (name, tag) where name includes org/repo
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
docker.io/hudpython/test_init:latest@sha256:... -> (hudpython/test_init, latest)
|
|
53
|
+
hudpython/myenv:v1.0 -> (hudpython/myenv, v1.0)
|
|
54
|
+
myorg/myapp -> (myorg/myapp, latest)
|
|
55
|
+
"""
|
|
56
|
+
# Remove digest if present
|
|
57
|
+
if "@" in image_ref:
|
|
58
|
+
image_ref = image_ref.split("@")[0]
|
|
59
|
+
|
|
60
|
+
# Remove registry prefix if present
|
|
61
|
+
if image_ref.startswith(("docker.io/", "registry-1.docker.io/", "index.docker.io/")):
|
|
62
|
+
image_ref = "/".join(image_ref.split("/")[1:])
|
|
63
|
+
|
|
64
|
+
# Extract tag
|
|
65
|
+
if ":" in image_ref:
|
|
66
|
+
name, tag = image_ref.rsplit(":", 1)
|
|
67
|
+
else:
|
|
68
|
+
name = image_ref
|
|
69
|
+
tag = "latest"
|
|
70
|
+
|
|
71
|
+
return name, tag
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_to_registry(
|
|
75
|
+
lock_data: Dict[str, Any],
|
|
76
|
+
image_ref: str,
|
|
77
|
+
verbose: bool = False
|
|
78
|
+
) -> Optional[Path]:
|
|
79
|
+
"""Save environment lock data to the local registry.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
lock_data: The lock file data to save
|
|
83
|
+
image_ref: Docker image reference for digest extraction
|
|
84
|
+
verbose: Whether to show verbose output
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Path to the saved lock file, or None if save failed
|
|
88
|
+
"""
|
|
89
|
+
design = HUDDesign()
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# Extract digest for registry storage
|
|
93
|
+
digest = extract_digest_from_image(image_ref)
|
|
94
|
+
|
|
95
|
+
# Store under ~/.hud/envs/<digest>/
|
|
96
|
+
local_env_dir = get_registry_dir() / digest
|
|
97
|
+
local_env_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
local_lock_path = local_env_dir / "hud.lock.yaml"
|
|
100
|
+
with open(local_lock_path, "w") as f:
|
|
101
|
+
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
102
|
+
|
|
103
|
+
design.success(f"Added to local registry: {digest}")
|
|
104
|
+
if verbose:
|
|
105
|
+
design.info(f"Registry location: {local_lock_path}")
|
|
106
|
+
|
|
107
|
+
return local_lock_path
|
|
108
|
+
except Exception as e:
|
|
109
|
+
if verbose:
|
|
110
|
+
design.warning(f"Failed to save to registry: {e}")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_from_registry(digest: str) -> Optional[Dict[str, Any]]:
|
|
115
|
+
"""Load environment lock data from the local registry.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
digest: The digest/identifier of the environment
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Lock data dictionary, or None if not found
|
|
122
|
+
"""
|
|
123
|
+
lock_path = get_registry_dir() / digest / "hud.lock.yaml"
|
|
124
|
+
|
|
125
|
+
if not lock_path.exists():
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with open(lock_path) as f:
|
|
130
|
+
return yaml.safe_load(f)
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def list_registry_entries() -> list[tuple[str, Path]]:
|
|
136
|
+
"""List all entries in the local registry.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
List of (digest, lock_path) tuples
|
|
140
|
+
"""
|
|
141
|
+
registry_dir = get_registry_dir()
|
|
142
|
+
|
|
143
|
+
if not registry_dir.exists():
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
entries = []
|
|
147
|
+
for digest_dir in registry_dir.iterdir():
|
|
148
|
+
if not digest_dir.is_dir():
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
lock_file = digest_dir / "hud.lock.yaml"
|
|
152
|
+
if lock_file.exists():
|
|
153
|
+
entries.append((digest_dir.name, lock_file))
|
|
154
|
+
|
|
155
|
+
return entries
|