hud-python 0.4.7__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/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
- # Extract digest from image ref
286
- digest = image_ref.split("@sha256:")[-1][:12] if "@sha256:" in image_ref else "latest"
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.utcnow().isoformat() + "Z",
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
- registry_parts = pushed_digest.split("/")
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 and get org/name:tag
339
- name_with_tag = "/".join(registry_parts[1:]).split("@")[0]
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
- # Just org/name:tag
342
- name_with_tag = "/".join(registry_parts[:2]).split("@")[0]
343
-
344
- # If no tag specified, use "latest"
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
- if response.status_code in [200, 201]:
364
- design.success("Metadata uploaded to HUD registry")
365
- design.info("Others can now pull with:")
366
- design.command_example(f"hud pull {name_with_tag}")
367
- design.info("")
368
- else:
369
- design.warning(f"Could not upload to registry: {response.status_code}")
370
- if verbose:
371
- design.info(f"Response: {response.text}")
372
- design.info("Share hud.lock.yaml manually\n")
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
- if verbose:
375
- design.info("Could not parse registry path for upload")
376
- design.info("Share hud.lock.yaml to let others reproduce your exact environment\n")
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\n")
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