hud-python 0.4.8__py3-none-any.whl → 0.4.10__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/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
hud/cli/remove.py ADDED
@@ -0,0 +1,200 @@
1
+ """Remove HUD environments from local registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from hud.utils.design import HUDDesign
12
+
13
+ from .registry import get_registry_dir, list_registry_entries, load_from_registry
14
+
15
+
16
+ def remove_environment(
17
+ target: str,
18
+ yes: bool = False,
19
+ verbose: bool = False,
20
+ ) -> None:
21
+ """Remove an environment from the local registry."""
22
+ design = HUDDesign()
23
+ design.header("HUD Environment Removal")
24
+
25
+ # Find the environment to remove
26
+ found_entry = None
27
+ found_digest = None
28
+
29
+ # First check if target is a digest
30
+ for digest, lock_file in list_registry_entries():
31
+ if digest.startswith(target):
32
+ found_entry = lock_file
33
+ found_digest = digest
34
+ break
35
+
36
+ # If not found by digest, search by name
37
+ if not found_entry:
38
+ for digest, lock_file in list_registry_entries():
39
+ try:
40
+ lock_data = load_from_registry(digest)
41
+ if lock_data and "image" in lock_data:
42
+ image = lock_data["image"]
43
+ # Extract name and tag
44
+ name = image.split("@")[0] if "@" in image else image
45
+ if "/" in name:
46
+ # Match by repo/name or just name
47
+ if target in name or name.endswith(f"/{target}"):
48
+ found_entry = lock_file
49
+ found_digest = digest
50
+ break
51
+ except Exception:
52
+ continue
53
+
54
+ if not found_entry:
55
+ design.error(f"Environment not found: {target}")
56
+ design.info("Use 'hud list' to see available environments")
57
+ raise typer.Exit(1)
58
+
59
+ # Load and display environment info
60
+ try:
61
+ lock_data = load_from_registry(found_digest)
62
+ if lock_data:
63
+ image = lock_data.get("image", "unknown")
64
+ metadata = lock_data.get("metadata", {})
65
+ description = metadata.get("description", "No description")
66
+
67
+ design.section_title("Environment Details")
68
+ design.status_item("Image", image)
69
+ design.status_item("Digest", found_digest)
70
+ design.status_item("Description", description)
71
+ design.status_item("Location", str(found_entry.parent))
72
+ except Exception as e:
73
+ if verbose:
74
+ design.warning(f"Could not read environment details: {e}")
75
+
76
+ # Confirm deletion
77
+ if not yes:
78
+ design.info("")
79
+ if not typer.confirm(f"Remove environment {found_digest}?"):
80
+ design.info("Aborted")
81
+ raise typer.Exit(0)
82
+
83
+ # Remove the environment directory
84
+ try:
85
+ env_dir = found_entry.parent
86
+ shutil.rmtree(env_dir)
87
+ design.success(f"Removed environment: {found_digest}")
88
+
89
+ # Check if the image is still available locally
90
+ if lock_data:
91
+ image = lock_data.get("image", "")
92
+ if image:
93
+ design.info("")
94
+ design.info("Note: The Docker image may still exist locally.")
95
+ design.info(f"To remove it, run: [cyan]docker rmi {image.split('@')[0]}[/cyan]")
96
+ except Exception as e:
97
+ design.error(f"Failed to remove environment: {e}")
98
+ raise typer.Exit(1)
99
+
100
+
101
+ def remove_all_environments(
102
+ yes: bool = False,
103
+ verbose: bool = False,
104
+ ) -> None:
105
+ """Remove all environments from the local registry."""
106
+ design = HUDDesign()
107
+ design.header("Remove All HUD Environments")
108
+
109
+ registry_dir = get_registry_dir()
110
+ if not registry_dir.exists():
111
+ design.info("No environments found in local registry.")
112
+ return
113
+
114
+ # Count environments
115
+ entries = list(list_registry_entries())
116
+ if not entries:
117
+ design.info("No environments found in local registry.")
118
+ return
119
+
120
+ design.warning(f"This will remove {len(entries)} environment(s) from the local registry!")
121
+
122
+ # List environments that will be removed
123
+ design.section_title("Environments to Remove")
124
+ for digest, lock_file in entries:
125
+ try:
126
+ lock_data = load_from_registry(digest)
127
+ if lock_data:
128
+ image = lock_data.get("image", "unknown")
129
+ design.info(f" • {digest[:12]} - {image}")
130
+ except Exception:
131
+ design.info(f" • {digest[:12]}")
132
+
133
+ # Confirm deletion
134
+ if not yes:
135
+ design.info("")
136
+ if not typer.confirm("Remove ALL environments?", default=False):
137
+ design.info("Aborted")
138
+ raise typer.Exit(0)
139
+
140
+ # Remove all environments
141
+ removed = 0
142
+ failed = 0
143
+
144
+ for digest, lock_file in entries:
145
+ try:
146
+ env_dir = lock_file.parent
147
+ shutil.rmtree(env_dir)
148
+ removed += 1
149
+ if verbose:
150
+ design.success(f"Removed: {digest}")
151
+ except Exception as e:
152
+ failed += 1
153
+ if verbose:
154
+ design.error(f"Failed to remove {digest}: {e}")
155
+
156
+ design.info("")
157
+ if failed == 0:
158
+ design.success(f"Successfully removed {removed} environment(s)")
159
+ else:
160
+ design.warning(f"Removed {removed} environment(s), failed to remove {failed}")
161
+
162
+ design.info("")
163
+ design.info("Note: Docker images may still exist locally.")
164
+ design.info("To remove them, use: [cyan]docker image prune[/cyan]")
165
+
166
+
167
+ def remove_command(
168
+ target: str | None = typer.Argument(
169
+ None,
170
+ help="Environment to remove (digest, name, or 'all' for all environments)"
171
+ ),
172
+ yes: bool = typer.Option(
173
+ False, "--yes", "-y", help="Skip confirmation prompt"
174
+ ),
175
+ verbose: bool = typer.Option(
176
+ False, "--verbose", "-v", help="Show detailed output"
177
+ ),
178
+ ) -> None:
179
+ """🗑️ Remove HUD environments from local registry.
180
+
181
+ Removes environment metadata from ~/.hud/envs/
182
+ Note: This does not remove the Docker images.
183
+
184
+ Examples:
185
+ hud remove abc123 # Remove by digest
186
+ hud remove text_2048 # Remove by name
187
+ hud remove hudpython/test_init # Remove by full name
188
+ hud remove all # Remove all environments
189
+ hud remove all --yes # Remove all without confirmation
190
+ """
191
+ if not target:
192
+ design = HUDDesign()
193
+ design.error("Please specify an environment to remove or 'all'")
194
+ design.info("Use 'hud list' to see available environments")
195
+ raise typer.Exit(1)
196
+
197
+ if target.lower() == "all":
198
+ remove_all_environments(yes, verbose)
199
+ else:
200
+ remove_environment(target, yes, verbose)
hud/cli/runner.py CHANGED
@@ -14,7 +14,7 @@ from hud.utils.design import HUDDesign
14
14
 
15
15
  def run_stdio_server(image: str, docker_args: list[str], verbose: bool) -> None:
16
16
  """Run Docker image as stdio MCP server (direct passthrough)."""
17
- design = HUDDesign(stderr=True) # Use stderr for stdio mode
17
+ design = HUDDesign() # Use stderr for stdio mode
18
18
 
19
19
  # Build docker command
20
20
  docker_cmd = ["docker", "run", "--rm", "-i", *docker_args, image]