hud-python 0.1.5__py3-none-any.whl → 0.2.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.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (46) hide show
  1. hud/__init__.py +16 -12
  2. hud/adapters/__init__.py +4 -2
  3. hud/adapters/claude/adapter.py +9 -2
  4. hud/adapters/common/adapter.py +11 -10
  5. hud/adapters/common/types.py +34 -13
  6. hud/adapters/operator/__init__.py +5 -0
  7. hud/adapters/operator/adapter.py +97 -0
  8. hud/agent/__init__.py +7 -0
  9. hud/agent/base.py +109 -0
  10. hud/agent/claude.py +207 -0
  11. hud/agent/operator.py +208 -0
  12. hud/env/__init__.py +11 -0
  13. hud/env/client.py +35 -0
  14. hud/env/docker_client.py +306 -0
  15. hud/env/environment.py +354 -0
  16. hud/env/local_docker_client.py +251 -0
  17. hud/env/remote_client.py +185 -0
  18. hud/env/remote_docker_client.py +221 -0
  19. hud/evaluators/__init__.py +10 -0
  20. hud/evaluators/base.py +31 -0
  21. hud/evaluators/inspect.py +29 -0
  22. hud/evaluators/judge.py +213 -0
  23. hud/evaluators/match.py +163 -0
  24. hud/evaluators/remote.py +78 -0
  25. hud/gym.py +101 -15
  26. hud/job.py +185 -0
  27. hud/server/__init__.py +2 -2
  28. hud/server/requests.py +87 -0
  29. hud/settings.py +13 -2
  30. hud/task.py +144 -0
  31. hud/taskset.py +103 -0
  32. hud/trajectory.py +90 -0
  33. hud/types.py +65 -0
  34. hud/utils/__init__.py +4 -2
  35. hud/utils/common.py +96 -0
  36. hud/utils/config.py +91 -4
  37. hud/utils/telemetry.py +67 -0
  38. hud_python-0.2.1.dist-info/METADATA +181 -0
  39. hud_python-0.2.1.dist-info/RECORD +44 -0
  40. {hud_python-0.1.5.dist-info → hud_python-0.2.1.dist-info}/licenses/LICENSE +1 -1
  41. hud/client.py +0 -200
  42. hud/environment.py +0 -318
  43. hud/run.py +0 -208
  44. hud_python-0.1.5.dist-info/METADATA +0 -125
  45. hud_python-0.1.5.dist-info/RECORD +0 -21
  46. {hud_python-0.1.5.dist-info → hud_python-0.2.1.dist-info}/WHEEL +0 -0
hud/task.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from hud.types import CustomGym, Gym
8
+ from hud.utils.common import HudStyleConfig, HudStyleConfigs
9
+
10
+ if TYPE_CHECKING:
11
+ from inspect_ai.dataset import Sample
12
+
13
+ # Environment specifications:
14
+ # These represent the environment as a whole, including both the controller
15
+ # and the environment type (eg, what os, which services are running)
16
+
17
+ UBUNTU_DOCKERFILE = "ubuntu:latest"
18
+
19
+
20
+ def convert_inspect_setup(setup: str) -> list[HudStyleConfig]:
21
+ """
22
+ Inspect setup is a single bash string to run in the environment.
23
+ We convert this into a single HudStyleConfig using the exec command
24
+ """
25
+ return [HudStyleConfig(function="bash", args=[setup])]
26
+
27
+
28
+ class Task(BaseModel):
29
+ """A task that can be executed and evaluated.
30
+
31
+ A Task represents a specific activity to be performed in an environment.
32
+ It contains the prompt describing the task and configurations for
33
+ setting up and evaluating the environment.
34
+
35
+ The setup and evaluate configurations can be in several formats:
36
+ - String (function name): "chrome.maximize"
37
+ - Tuple (function with args): ("chrome.activate_tab", 5)
38
+ - Dict: {"function": "chrome.navigate", "args": ["https://example.com"]}
39
+ - List of the above: ["chrome.maximize", {"function": "chrome.navigate", "args": ["https://example.com"]}]
40
+
41
+ Attributes:
42
+ id: The remote task ID (optional if local-only)
43
+ prompt: The task prompt or instruction
44
+ setup: Environment setup configuration (optional)
45
+ evaluate: Configuration for evaluating responses
46
+ metadata: Additional task metadata
47
+ choices: Multiple choice answer list (for Inspect compatibility)
48
+ target: Ideal target output (for Inspect compatibility)
49
+ files: Files that go along with the task (for Inspect compatibility)
50
+ gym: Environment specification
51
+ """
52
+
53
+ id: str | None = None
54
+ prompt: str
55
+ setup: HudStyleConfigs | None = None
56
+ evaluate: HudStyleConfigs | None = None
57
+ gym: Gym | None = None
58
+
59
+ target: str | list[str] | None = None
60
+
61
+ choices: list[str] | None = None
62
+ files: dict[str, str] | None = None
63
+ metadata: dict[str, Any] | None = None
64
+
65
+ config: dict[str, Any] | None = None
66
+
67
+ @classmethod
68
+ def from_inspect_sample(cls, sample: Sample) -> Task:
69
+ """Create a Task from an Inspect dataset sample.
70
+ Automatically detects if a CustomGym (docker) or QA Gym is needed based on sample.sandbox.
71
+ Configures evaluation using 'response_includes' or 'match_all' based on sample.target.
72
+
73
+ Args:
74
+ sample: An Inspect dataset Sample object
75
+
76
+ Returns:
77
+ Task instance
78
+
79
+ The Inspect Sample has these fields:
80
+ - input (str | list[ChatMessage]): The input to be submitted to the model
81
+ - choices (list[str] | None): Optional multiple choice answer list
82
+ - target (str | list[str] | None): Optional ideal target output
83
+ - id (str | None): Optional unique identifier for sample
84
+ - metadata (dict[str, Any] | None): Optional arbitrary metadata
85
+ - sandbox (str | tuple[str, str]): Optional sandbox environment type
86
+ - files (dict[str, str] | None): Optional files that go with the sample
87
+ - setup (str | None): Optional setup script to run for sample
88
+ """
89
+ prompt = sample.input
90
+ if isinstance(prompt, list):
91
+ prompt_parts = []
92
+ for message in prompt:
93
+ role = message.role
94
+ content = message.content
95
+ prompt_parts.append(f"{role.capitalize()}: {content}")
96
+ prompt = "\n\n".join(prompt_parts)
97
+
98
+ evaluate_config = None
99
+ if sample.target:
100
+ if isinstance(sample.target, str):
101
+ evaluate_config = ("response_includes", [sample.target])
102
+ elif isinstance(sample.target, list):
103
+ evaluate_config = ("match_all", sample.target)
104
+
105
+ task_gym: Gym | None = None
106
+ task_setup: HudStyleConfigs | None = None
107
+
108
+ sandbox = sample.sandbox
109
+ dockerfile = None
110
+ use_qa_gym = True
111
+
112
+ if sandbox:
113
+ if isinstance(sandbox, str):
114
+ if sandbox == "docker":
115
+ dockerfile = UBUNTU_DOCKERFILE
116
+ use_qa_gym = False
117
+ elif isinstance(sandbox, tuple) and len(sandbox) == 2:
118
+ sandbox_type, sandbox_config = sandbox
119
+ if sandbox_type == "docker":
120
+ dockerfile = sandbox_config
121
+ use_qa_gym = False
122
+
123
+ if use_qa_gym:
124
+ task_gym = "qa"
125
+ task_setup = None
126
+ else:
127
+ task_gym = CustomGym(
128
+ dockerfile=dockerfile or UBUNTU_DOCKERFILE,
129
+ location="local",
130
+ )
131
+ task_setup = [x for x in convert_inspect_setup(sample.setup)] if sample.setup else None
132
+ # TODO: Handle sample.files for CustomGym case if needed
133
+
134
+
135
+ return cls(
136
+ id=None,
137
+ prompt=prompt,
138
+ setup=task_setup,
139
+ metadata=sample.metadata,
140
+ choices=sample.choices,
141
+ evaluate=evaluate_config,
142
+ gym=task_gym,
143
+ # files=sample.files, # TODO: Decide how/if to handle files
144
+ )
hud/taskset.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from hud.server import make_request
8
+ from hud.settings import settings
9
+ from hud.task import Task
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ from inspect_ai.dataset import Dataset
15
+
16
+
17
+ class TaskSet(BaseModel):
18
+ """
19
+ Collection of related tasks for benchmarking.
20
+
21
+ Attributes:
22
+ id: Unique identifier for the taskset
23
+ description: Description of the taskset
24
+ tasks: List of Task objects in the taskset
25
+ """
26
+ id: str | None = None
27
+ description: str | None = None
28
+ tasks: list[Task] = []
29
+
30
+ def __getitem__(self, index: int) -> Task:
31
+ """
32
+ Allows accessing tasks by index using square bracket notation.
33
+
34
+ Args:
35
+ index: The index of the task to retrieve
36
+
37
+ Returns:
38
+ Task: The task at the specified index
39
+
40
+ Raises:
41
+ IndexError: If the index is out of range
42
+ """
43
+ return self.tasks[index]
44
+
45
+ def __len__(self) -> int:
46
+ """
47
+ Returns the number of tasks in the taskset.
48
+
49
+ Returns:
50
+ int: The number of tasks in the taskset
51
+ """
52
+ return len(self.tasks)
53
+
54
+ def __iter__(self) -> Iterator[Task]:
55
+ """
56
+ Returns an iterator over the tasks in the taskset.
57
+ """
58
+ return iter(self.tasks)
59
+
60
+
61
+ async def load_taskset(taskset_id: str, api_key: str | None = None) -> TaskSet:
62
+ """
63
+ Loads a TaskSet by its ID.
64
+
65
+ Args:
66
+ taskset_id: The ID of the taskset to load
67
+ api_key: Optional API key to use for the request
68
+
69
+ Returns:
70
+ TaskSet: The loaded taskset
71
+ """
72
+
73
+ if api_key is None:
74
+ api_key = settings.api_key
75
+
76
+ data = await make_request(
77
+ method="GET",
78
+ url=f"{settings.base_url}/v2/tasksets/{taskset_id}/tasks",
79
+ api_key=api_key,
80
+ )
81
+
82
+ return TaskSet.model_validate({
83
+ "id": taskset_id,
84
+ "tasks": data["evalset"],
85
+ })
86
+
87
+ def load_from_inspect(dataset: Dataset) -> TaskSet:
88
+ """
89
+ Creates a TaskSet from an inspect-ai dataset.
90
+
91
+ Args:
92
+ dataset: An inspect-ai dataset
93
+
94
+ Returns:
95
+ TaskSet: A new TaskSet instance
96
+ """
97
+ tasks = [Task.from_inspect_sample(sample) for sample in dataset]
98
+
99
+ return TaskSet(
100
+ id=None,
101
+ tasks=tasks,
102
+ description=dataset.name,
103
+ )
hud/trajectory.py ADDED
@@ -0,0 +1,90 @@
1
+ # ruff: noqa: T201
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+
6
+ from IPython.display import HTML, Markdown, display
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class TrajectoryStep(BaseModel):
11
+ """Model representing a single task run's trajectory information."""
12
+
13
+ observation_url: str | None = None
14
+ observation_text: str | None = None
15
+ actions: list[dict]
16
+ start_timestamp: str | None = None
17
+ end_timestamp: str | None = None
18
+
19
+
20
+ class Trajectory(BaseModel):
21
+ """Model representing a single task run's trajectory information."""
22
+
23
+ id: str
24
+ reward: float | None = None
25
+ logs: str | None = None
26
+ error: str | None = None
27
+ trajectory: list[TrajectoryStep] = Field(default_factory=list)
28
+
29
+ def display(self) -> None:
30
+ trajectory_start_timestamp_str = self.trajectory[0].start_timestamp
31
+ t_start_dt = (
32
+ datetime.datetime.fromisoformat(
33
+ trajectory_start_timestamp_str.replace("Z", "+00:00")
34
+ )
35
+ if trajectory_start_timestamp_str
36
+ else None
37
+ )
38
+ for i, step in enumerate(self.trajectory):
39
+ # Use Markdown for better step separation in Jupyter
40
+ display(Markdown(f"### Step {i + 1}"))
41
+
42
+ # Observation Image
43
+ if step.observation_url:
44
+ try:
45
+ # Display in Jupyter/IPython environment using HTML
46
+ display(Markdown("**Observation Image:**"))
47
+ display(HTML(f'<img src="{step.observation_url}" style="max-width:100%;"/>'))
48
+ display(Markdown(f"[Image Link]({step.observation_url})"))
49
+ except Exception as e:
50
+ print(f" [Error processing image: {e}]")
51
+ elif not step.observation_text: # Only print if no image AND no text
52
+ print(" No visual or text observation provided.")
53
+
54
+
55
+ # Observation Text
56
+ if step.observation_text:
57
+ print(f" Observation Text: {step.observation_text}")
58
+
59
+ # Actions
60
+ print(f"\n Actions: {step.actions}") # Added newline for spacing
61
+
62
+ # Duration
63
+ duration_str = "N/A"
64
+ step_start_timestamp = self.trajectory[i].start_timestamp
65
+ step_end_timestamp = self.trajectory[i].end_timestamp
66
+ if step_start_timestamp and step_end_timestamp and t_start_dt:
67
+ try:
68
+ # Attempt to parse timestamps (assuming ISO format)
69
+ start_dt = datetime.datetime.fromisoformat(
70
+ step_start_timestamp.replace("Z", "+00:00")
71
+ )
72
+ end_dt = datetime.datetime.fromisoformat(
73
+ step_end_timestamp.replace("Z", "+00:00")
74
+ )
75
+ duration = end_dt - start_dt
76
+ total_seconds = duration.total_seconds()
77
+ minutes = int(total_seconds // 60)
78
+ seconds = total_seconds % 60
79
+ duration_str = f"{minutes}m {seconds:.2f}s"
80
+
81
+ # Calculate the total duration up to this step
82
+ total_duration = end_dt - t_start_dt
83
+ total_minutes = int(total_duration.total_seconds() // 60)
84
+ total_seconds = total_duration.total_seconds() % 60
85
+ total_duration_str = f"{total_minutes}m {total_seconds:.2f}s"
86
+ except ValueError:
87
+ duration_str = "Error parsing timestamps" # Handle potential format issues
88
+ print(f" Step Duration: {duration_str}")
89
+ print(f" Total Duration: {total_duration_str}")
90
+ display(Markdown("---")) # Use Markdown horizontal rule
hud/types.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class CustomGym(BaseModel):
11
+ """
12
+ Public environment specification with a dockerfile and controller.
13
+
14
+ If the location is remote, the env will be created on the server.
15
+ If the location is dev, the env will be created locally via docker.
16
+
17
+ The dockerfile can be specified directly or automatically found in the controller_source_dir.
18
+ If neither is provided, an error will be raised during validation.
19
+ """
20
+ type: Literal["public"] = "public"
21
+ dockerfile: str | None = None
22
+ location: Literal["local", "remote"]
23
+ ports: list[int] | None = None
24
+ # If path, then it is a development environment on the local computer
25
+ # If none, then the controller must be installed in the environment through the dockerfile
26
+ # Can be provided as a string or Path object
27
+ controller_source_dir: str | Path | None = None
28
+
29
+ def model_post_init(self, __context: Any, /) -> None:
30
+ """Validate and set up dockerfile if not explicitly provided."""
31
+ # Convert string path to Path object if needed
32
+ if isinstance(self.controller_source_dir, str):
33
+ self.controller_source_dir = Path(self.controller_source_dir)
34
+
35
+ if self.dockerfile is None:
36
+ if self.controller_source_dir is None:
37
+ raise ValueError("Either dockerfile or controller_source_dir must be provided")
38
+
39
+ # Look for Dockerfile in the controller_source_dir
40
+ dockerfile_path = self.controller_source_dir / "Dockerfile"
41
+ if not dockerfile_path.exists():
42
+ raise ValueError(f"Dockerfile not found in {self.controller_source_dir}")
43
+
44
+ # Read the Dockerfile content
45
+ self.dockerfile = dockerfile_path.read_text()
46
+
47
+ # Strings are identifiers for gyms on the HUD server
48
+ Gym = CustomGym | str
49
+
50
+ class EnvironmentStatus(str, enum.Enum):
51
+ """
52
+ Status of the environment.
53
+
54
+ Attributes:
55
+ INITIALIZING: The environment is initializing
56
+ RUNNING: The environment is running
57
+ COMPLETED: The environment is completed
58
+ ERROR: The environment is in an error state
59
+ """
60
+
61
+ INITIALIZING = "initializing"
62
+ RUNNING = "running"
63
+ COMPLETED = "completed"
64
+ ERROR = "error"
65
+
hud/utils/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from .config import configuration
3
+ from .common import ExecuteResult
4
+ from .config import HudStyleConfig, HudStyleConfigs, expand_config
5
+ from .telemetry import stream
4
6
 
5
- __all__ = ["configuration"]
7
+ __all__ = ["ExecuteResult", "HudStyleConfig", "HudStyleConfigs", "expand_config", "stream"]
hud/utils/common.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import logging
5
+ import tarfile
6
+ from typing import TYPE_CHECKING, Any, TypedDict
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from hud.server.requests import make_request
11
+ from hud.settings import settings
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger("hud.utils.common")
18
+
19
+ class HudStyleConfig(BaseModel):
20
+ function: str # Format: "x.y.z"
21
+ args: list[Any] # Must be json serializable
22
+
23
+ id: str | None = None # Optional id for remote execution
24
+
25
+ def __len__(self) -> int:
26
+ return len(self.args)
27
+
28
+ def __getitem__(self, index: int) -> Any:
29
+ return self.args[index]
30
+
31
+ def __iter__(self) -> Iterator[Any]:
32
+ return iter(self.args)
33
+
34
+ def __str__(self) -> str:
35
+ return f"{self.function}: {', '.join(str(arg) for arg in self.args)}"
36
+
37
+ # Type alias for the shorthand config, which just converts to function name and args
38
+ ShorthandConfig = tuple[str | dict[str, Any] | list[str] | list[dict[str, Any]], ...]
39
+
40
+ # Type alias for multiple config formats
41
+ HudStyleConfigs = ShorthandConfig | HudStyleConfig | list[HudStyleConfig] | dict[str, Any] | str
42
+
43
+ class ExecuteResult(TypedDict):
44
+ """
45
+ Result of an execute command.
46
+
47
+ Attributes:
48
+ stdout: Standard output from the command
49
+ stderr: Standard error from the command
50
+ exit_code: Exit code of the command
51
+ """
52
+ stdout: bytes
53
+ stderr: bytes
54
+ exit_code: int
55
+
56
+
57
+ def directory_to_tar_bytes(directory_path: Path) -> bytes:
58
+ """
59
+ Converts a directory to a tar archive and returns it as bytes.
60
+
61
+ This function creates a tar archive of the specified directory in memory,
62
+ without writing to a temporary file on disk.
63
+
64
+ Args:
65
+ path: Path to the directory to convert
66
+
67
+ Returns:
68
+ Bytes of the tar archive
69
+ """
70
+ output = io.BytesIO()
71
+
72
+ with tarfile.open(fileobj=output, mode="w") as tar:
73
+ # Walk through the directory
74
+ for file_path in directory_path.rglob("*"):
75
+ if file_path.is_file():
76
+ # Calculate relative path for the archive
77
+ rel_path = file_path.relative_to(directory_path)
78
+ logger.debug("Adding %s to tar archive", rel_path)
79
+ tar.add(file_path, arcname=str(rel_path))
80
+
81
+ # Get the bytes from the BytesIO object
82
+ output.seek(0)
83
+ return output.getvalue()
84
+
85
+
86
+ async def get_gym_id(gym_name_or_id: str) -> str:
87
+ """
88
+ Get the gym ID for a given gym name or ID.
89
+ """
90
+ data = await make_request(
91
+ method="GET",
92
+ url=f"{settings.base_url}/v1/gyms/{gym_name_or_id}",
93
+ api_key=settings.api_key,
94
+ )
95
+
96
+ return data["id"]
hud/utils/config.py CHANGED
@@ -1,7 +1,94 @@
1
1
  from __future__ import annotations
2
2
 
3
- from hud.settings import settings
3
+ import logging
4
+ import re
4
5
 
5
- # For backwards compatibility, keep 'configuration'
6
- # but have it point to the settings instance
7
- configuration = settings
6
+ from hud.utils.common import HudStyleConfig, HudStyleConfigs
7
+
8
+ logger = logging.getLogger("hud.utils.config")
9
+
10
+ REMOTE_FUNCTION_PREFIX = "private_"
11
+ REMOTE_SETUP = "setup"
12
+ REMOTE_EVALUATE = "evaluate"
13
+
14
+ def _is_valid_python_name(name: str) -> bool:
15
+ """Check if a string is a valid Python identifier."""
16
+ return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name))
17
+
18
+ def _validate_hud_config(config: dict) -> HudStyleConfig:
19
+ """Validate and convert a dictionary to an HudStyleConfig."""
20
+ if not isinstance(config.get("function"), str):
21
+ raise ValueError("function must be a string")
22
+
23
+ # Validate function path components
24
+ _split_and_validate_path(config["function"])
25
+
26
+ args = config["args"] if isinstance(config.get("args"), list) else [config["args"]]
27
+
28
+ # Create a proper HudStyleConfig object instead of using cast
29
+ return HudStyleConfig(function=config["function"], args=args, id=config.get("id"))
30
+
31
+ def _split_and_validate_path(path: str) -> None:
32
+ """Split a function path into components, validating each part."""
33
+ parts = path.split(".")
34
+
35
+ if not parts:
36
+ raise ValueError("Empty function path")
37
+
38
+ # Validate each part
39
+ for part in parts:
40
+ if not _is_valid_python_name(part):
41
+ raise ValueError(f"Invalid Python identifier in path: {part}")
42
+
43
+ def expand_config(config: HudStyleConfigs) -> list[HudStyleConfig]:
44
+ """
45
+ Process a config into a standardized list of HudStyleConfig objects.
46
+
47
+ Args:
48
+ config: Can be:
49
+ - A tuple where first element is function name and rest are args
50
+ - A HudStyleConfig object
51
+ - A dictionary with "function" and "args" keys
52
+ - A list of HudStyleConfig objects
53
+
54
+ Returns:
55
+ list[HudStyleConfig]: List of standardized configurations
56
+
57
+ Raises:
58
+ ValueError: If the configuration format is invalid
59
+ """
60
+ logger.debug("Processing config: %s", config)
61
+
62
+ # If it's already a HudStyleConfig, just wrap it in a list
63
+ if isinstance(config, HudStyleConfig):
64
+ return [config]
65
+
66
+ # If it's a list of HudStyleConfigs, return as is
67
+ if isinstance(config, list) and all(isinstance(item, HudStyleConfig) for item in config):
68
+ return config
69
+
70
+ # Handle dictionary configuration
71
+ if isinstance(config, dict):
72
+ return [_validate_hud_config(config)]
73
+
74
+ if isinstance(config, str):
75
+ return [HudStyleConfig(function=config, args=[])]
76
+
77
+ # Handle tuple format
78
+ if isinstance(config, tuple):
79
+ if len(config) < 1 or not isinstance(config[0], str):
80
+ error_msg = "Invalid tuple configuration. "
81
+ "Expected tuple[str, ...], got: {type(config)}"
82
+ logger.error(error_msg)
83
+ raise ValueError(error_msg)
84
+
85
+ # First element is the function name, rest are args
86
+ function_name = config[0]
87
+ args = list(config[1:]) if len(config) > 1 else []
88
+
89
+ return [HudStyleConfig(function=function_name, args=args)]
90
+
91
+ # Unknown configuration type
92
+ error_msg = f"Unknown configuration type: {type(config)}"
93
+ logger.error(error_msg)
94
+ raise ValueError(error_msg)
hud/utils/telemetry.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def stream(live_url: str | None = None) -> str:
8
+ """
9
+ Display a stream in the HUD system.
10
+ """
11
+ if live_url is None:
12
+ raise ValueError("live_url cannot be None")
13
+ from IPython.display import HTML, display
14
+
15
+ html_content = f"""
16
+ <div style="width: 960px; height: 540px; overflow: hidden;">
17
+ <div style="transform: scale(0.5); transform-origin: top left;">
18
+ <iframe src="{live_url}" width="1920" height="1080" style="border: 1px solid #ddd;">
19
+ </iframe>
20
+ </div>
21
+ </div>
22
+ """
23
+ try:
24
+ display(HTML(html_content))
25
+ except Exception as e:
26
+ logger.warning(e)
27
+
28
+ return html_content
29
+
30
+
31
+ def display_screenshot(base64_image: str, width: int = 960, height: int = 540) -> str:
32
+ """
33
+ Display a base64-encoded screenshot image.
34
+
35
+ Args:
36
+ base64_image: Base64-encoded image string (without the data URI prefix)
37
+ width: Display width in pixels
38
+ height: Display height in pixels
39
+
40
+ Returns:
41
+ The HTML string used to display the image
42
+
43
+ Note:
44
+ This function will both display the image in IPython environments
45
+ and return the HTML string for other contexts.
46
+ """
47
+ from IPython.display import HTML, display
48
+
49
+ # Ensure the base64 image doesn't already have the data URI prefix
50
+ if base64_image.startswith("data:image"):
51
+ img_src = base64_image
52
+ else:
53
+ img_src = f"data:image/png;base64,{base64_image}"
54
+
55
+ html_content = f"""
56
+ <div style="width: {width}px; height: {height}px; overflow: hidden; margin: 10px 0; border: 1px solid #ddd;">
57
+ <img src="{img_src}" style="max-width: 100%; max-height: 100%;">
58
+ </div>
59
+ """ # noqa: E501
60
+
61
+ # Display in IPython environments
62
+ try:
63
+ display(HTML(html_content))
64
+ except Exception as e:
65
+ logger.warning(e)
66
+
67
+ return html_content