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.
- hud/__init__.py +16 -12
- hud/adapters/__init__.py +4 -2
- hud/adapters/claude/adapter.py +9 -2
- hud/adapters/common/adapter.py +11 -10
- hud/adapters/common/types.py +34 -13
- hud/adapters/operator/__init__.py +5 -0
- hud/adapters/operator/adapter.py +97 -0
- hud/agent/__init__.py +7 -0
- hud/agent/base.py +109 -0
- hud/agent/claude.py +207 -0
- hud/agent/operator.py +208 -0
- hud/env/__init__.py +11 -0
- hud/env/client.py +35 -0
- hud/env/docker_client.py +306 -0
- hud/env/environment.py +354 -0
- hud/env/local_docker_client.py +251 -0
- hud/env/remote_client.py +185 -0
- hud/env/remote_docker_client.py +221 -0
- hud/evaluators/__init__.py +10 -0
- hud/evaluators/base.py +31 -0
- hud/evaluators/inspect.py +29 -0
- hud/evaluators/judge.py +213 -0
- hud/evaluators/match.py +163 -0
- hud/evaluators/remote.py +78 -0
- hud/gym.py +101 -15
- hud/job.py +185 -0
- hud/server/__init__.py +2 -2
- hud/server/requests.py +87 -0
- hud/settings.py +13 -2
- hud/task.py +144 -0
- hud/taskset.py +103 -0
- hud/trajectory.py +90 -0
- hud/types.py +65 -0
- hud/utils/__init__.py +4 -2
- hud/utils/common.py +96 -0
- hud/utils/config.py +91 -4
- hud/utils/telemetry.py +67 -0
- hud_python-0.2.1.dist-info/METADATA +181 -0
- hud_python-0.2.1.dist-info/RECORD +44 -0
- {hud_python-0.1.5.dist-info → hud_python-0.2.1.dist-info}/licenses/LICENSE +1 -1
- hud/client.py +0 -200
- hud/environment.py +0 -318
- hud/run.py +0 -208
- hud_python-0.1.5.dist-info/METADATA +0 -125
- hud_python-0.1.5.dist-info/RECORD +0 -21
- {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 .
|
|
3
|
+
from .common import ExecuteResult
|
|
4
|
+
from .config import HudStyleConfig, HudStyleConfigs, expand_config
|
|
5
|
+
from .telemetry import stream
|
|
4
6
|
|
|
5
|
-
__all__ = ["
|
|
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
|
-
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|