humalab 0.1.0__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.
- humalab/__init__.py +34 -0
- humalab/assets/__init__.py +10 -0
- humalab/assets/archive.py +101 -0
- humalab/assets/files/__init__.py +4 -0
- humalab/assets/files/resource_file.py +131 -0
- humalab/assets/files/urdf_file.py +103 -0
- humalab/assets/resource_operator.py +139 -0
- humalab/constants.py +50 -0
- humalab/dists/__init__.py +24 -0
- humalab/dists/bernoulli.py +84 -0
- humalab/dists/categorical.py +81 -0
- humalab/dists/discrete.py +103 -0
- humalab/dists/distribution.py +49 -0
- humalab/dists/gaussian.py +96 -0
- humalab/dists/log_uniform.py +97 -0
- humalab/dists/truncated_gaussian.py +129 -0
- humalab/dists/uniform.py +95 -0
- humalab/episode.py +306 -0
- humalab/humalab.py +219 -0
- humalab/humalab_api_client.py +966 -0
- humalab/humalab_config.py +122 -0
- humalab/humalab_test.py +527 -0
- humalab/metrics/__init__.py +17 -0
- humalab/metrics/code.py +59 -0
- humalab/metrics/metric.py +96 -0
- humalab/metrics/scenario_stats.py +163 -0
- humalab/metrics/summary.py +75 -0
- humalab/run.py +325 -0
- humalab/scenarios/__init__.py +11 -0
- humalab/scenarios/scenario.py +375 -0
- humalab/scenarios/scenario_operator.py +114 -0
- humalab/scenarios/scenario_test.py +792 -0
- humalab/utils.py +37 -0
- humalab-0.1.0.dist-info/METADATA +43 -0
- humalab-0.1.0.dist-info/RECORD +39 -0
- humalab-0.1.0.dist-info/WHEEL +5 -0
- humalab-0.1.0.dist-info/entry_points.txt +2 -0
- humalab-0.1.0.dist-info/licenses/LICENSE +21 -0
- humalab-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
"""HTTP client for accessing HumaLab service APIs with API key authentication."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
import os
|
|
5
|
+
import requests
|
|
6
|
+
from typing import Dict, Any, Optional, List
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
from humalab.humalab_config import HumalabConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunStatus(Enum):
|
|
12
|
+
"""Status of runs"""
|
|
13
|
+
RUNNING = "running"
|
|
14
|
+
CANCELED = "canceled"
|
|
15
|
+
ERRORED = "errored"
|
|
16
|
+
FINISHED = "finished"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EpisodeStatus(Enum):
|
|
20
|
+
"""Status of validation episodes"""
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
CANCELED = "canceled"
|
|
23
|
+
ERRORED = "errored"
|
|
24
|
+
SUCCESS = "success"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HumaLabApiClient:
|
|
29
|
+
"""HTTP client for making authenticated requests to HumaLab service APIs."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
timeout: float | None = None
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Initialize the HumaLab API client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
base_url: Base URL for the HumaLab service (defaults to https://api.humalab.ai)
|
|
42
|
+
api_key: API key for authentication (defaults to HUMALAB_API_KEY env var)
|
|
43
|
+
timeout: Request timeout in seconds
|
|
44
|
+
"""
|
|
45
|
+
humalab_config = HumalabConfig()
|
|
46
|
+
self.base_url = base_url or humalab_config.base_url or os.getenv("HUMALAB_SERVICE_URL", "https://api.humalab.ai")
|
|
47
|
+
self.api_key = api_key or humalab_config.api_key or os.getenv("HUMALAB_API_KEY")
|
|
48
|
+
self.timeout = timeout or humalab_config.timeout or 30.0 # Default timeout of 30 seconds
|
|
49
|
+
|
|
50
|
+
# Ensure base_url ends without trailing slash
|
|
51
|
+
self.base_url = self.base_url.rstrip('/')
|
|
52
|
+
|
|
53
|
+
if not self.api_key:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"API key is required. Set HUMALAB_API_KEY environment variable "
|
|
56
|
+
"or pass api_key parameter to HumaLabApiClient constructor."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
60
|
+
"""Get common headers for API requests."""
|
|
61
|
+
return {
|
|
62
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"User-Agent": "HumaLab-SDK/1.0"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def _make_request(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
endpoint: str,
|
|
71
|
+
data: Optional[Dict[str, Any]] = None,
|
|
72
|
+
params: Optional[Dict[str, Any]] = None,
|
|
73
|
+
files: Optional[Dict[str, Any]] = None,
|
|
74
|
+
**kwargs
|
|
75
|
+
) -> requests.Response:
|
|
76
|
+
"""
|
|
77
|
+
Make an HTTP request to the HumaLab service.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
81
|
+
endpoint: API endpoint (will be joined with base_url)
|
|
82
|
+
data: JSON data for request body
|
|
83
|
+
params: Query parameters
|
|
84
|
+
files: Files for multipart upload
|
|
85
|
+
**kwargs: Additional arguments passed to requests
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
requests.Response object
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
requests.exceptions.RequestException: For HTTP errors
|
|
92
|
+
"""
|
|
93
|
+
url = urljoin(self.base_url + "/", endpoint.lstrip('/'))
|
|
94
|
+
headers = self._get_headers()
|
|
95
|
+
|
|
96
|
+
# If files are being uploaded, don't set Content-Type (let requests handle it)
|
|
97
|
+
if files:
|
|
98
|
+
headers.pop("Content-Type", None)
|
|
99
|
+
|
|
100
|
+
# Determine if we should send form data or JSON
|
|
101
|
+
# Form data endpoints: /artifacts/code, /artifacts/blob/upload, /artifacts/python
|
|
102
|
+
is_form_endpoint = any(form_path in endpoint for form_path in ['/artifacts/code', '/artifacts/blob', '/artifacts/python'])
|
|
103
|
+
|
|
104
|
+
if is_form_endpoint or files:
|
|
105
|
+
# Send as form data
|
|
106
|
+
headers.pop("Content-Type", None) # Let requests set multipart/form-data
|
|
107
|
+
response = requests.request(
|
|
108
|
+
method=method,
|
|
109
|
+
url=url,
|
|
110
|
+
data=data,
|
|
111
|
+
params=params,
|
|
112
|
+
files=files,
|
|
113
|
+
headers=headers,
|
|
114
|
+
timeout=self.timeout,
|
|
115
|
+
**kwargs
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
# Send as JSON (default behavior)
|
|
119
|
+
response = requests.request(
|
|
120
|
+
method=method,
|
|
121
|
+
url=url,
|
|
122
|
+
json=data,
|
|
123
|
+
params=params,
|
|
124
|
+
files=files,
|
|
125
|
+
headers=headers,
|
|
126
|
+
timeout=self.timeout,
|
|
127
|
+
**kwargs
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Raise an exception for HTTP error responses
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
|
|
133
|
+
return response
|
|
134
|
+
|
|
135
|
+
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs) -> requests.Response:
|
|
136
|
+
"""Make a GET request."""
|
|
137
|
+
return self._make_request("GET", endpoint, params=params, **kwargs)
|
|
138
|
+
|
|
139
|
+
def post(
|
|
140
|
+
self,
|
|
141
|
+
endpoint: str,
|
|
142
|
+
data: Optional[Dict[str, Any]] = None,
|
|
143
|
+
files: Optional[Dict[str, Any]] = None,
|
|
144
|
+
**kwargs
|
|
145
|
+
) -> requests.Response:
|
|
146
|
+
"""Make a POST request."""
|
|
147
|
+
return self._make_request("POST", endpoint, data=data, files=files, **kwargs)
|
|
148
|
+
|
|
149
|
+
def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> requests.Response:
|
|
150
|
+
"""Make a PUT request."""
|
|
151
|
+
return self._make_request("PUT", endpoint, data=data, **kwargs)
|
|
152
|
+
|
|
153
|
+
def delete(self, endpoint: str, **kwargs) -> requests.Response:
|
|
154
|
+
"""Make a DELETE request."""
|
|
155
|
+
return self._make_request("DELETE", endpoint, **kwargs)
|
|
156
|
+
|
|
157
|
+
# User Authentication API methods
|
|
158
|
+
def validate_token(self) -> Dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Validate JWT token and return user info.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
User information from the validated token
|
|
164
|
+
"""
|
|
165
|
+
response = self.get("/auth/validate")
|
|
166
|
+
return response.json()
|
|
167
|
+
|
|
168
|
+
# Convenience methods for common API operations
|
|
169
|
+
|
|
170
|
+
def get_resources(
|
|
171
|
+
self,
|
|
172
|
+
project_name: str,
|
|
173
|
+
resource_types: Optional[str] = None,
|
|
174
|
+
limit: int = 20,
|
|
175
|
+
offset: int = 0,
|
|
176
|
+
latest_only: bool = True
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
"""
|
|
179
|
+
Get list of all resources.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
project_name: Project name (required)
|
|
183
|
+
resource_types: Comma-separated resource types to filter by
|
|
184
|
+
limit: Maximum number of resources to return
|
|
185
|
+
offset: Number of resources to skip
|
|
186
|
+
latest_only: If true, only return latest version of each resource
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Resource list with pagination info
|
|
190
|
+
"""
|
|
191
|
+
params = {
|
|
192
|
+
"project_name": project_name,
|
|
193
|
+
"limit": limit,
|
|
194
|
+
"offset": offset,
|
|
195
|
+
"latest_only": latest_only
|
|
196
|
+
}
|
|
197
|
+
if resource_types:
|
|
198
|
+
params["resource_types"] = resource_types
|
|
199
|
+
|
|
200
|
+
response = self.get("/resources", params=params)
|
|
201
|
+
return response.json()
|
|
202
|
+
|
|
203
|
+
def get_resource(self, name: str, project_name: str, version: Optional[int] = None) -> Dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Get a specific resource.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
name: Resource name
|
|
209
|
+
project_name: Project name (required)
|
|
210
|
+
version: Optional specific version (defaults to latest)
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Resource data
|
|
214
|
+
"""
|
|
215
|
+
if version is not None:
|
|
216
|
+
endpoint = f"/resources/{name}/{version}"
|
|
217
|
+
params = {"project_name": project_name}
|
|
218
|
+
else:
|
|
219
|
+
endpoint = f"/resources/{name}"
|
|
220
|
+
params = {"project_name": project_name}
|
|
221
|
+
|
|
222
|
+
response = self.get(endpoint, params=params)
|
|
223
|
+
return response.json()
|
|
224
|
+
|
|
225
|
+
def download_resource(
|
|
226
|
+
self,
|
|
227
|
+
name: str,
|
|
228
|
+
project_name: str,
|
|
229
|
+
version: Optional[int] = None
|
|
230
|
+
) -> bytes:
|
|
231
|
+
"""
|
|
232
|
+
Download a resource file.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: Resource name
|
|
236
|
+
project_name: Project name (required)
|
|
237
|
+
version: Optional specific version (defaults to latest)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Resource file content as bytes
|
|
241
|
+
"""
|
|
242
|
+
endpoint = f"/resources/{name}/download"
|
|
243
|
+
params = {"project_name": project_name}
|
|
244
|
+
if version is not None:
|
|
245
|
+
params["version"] = str(version)
|
|
246
|
+
|
|
247
|
+
response = self.get(endpoint, params=params)
|
|
248
|
+
return response.content
|
|
249
|
+
|
|
250
|
+
def upload_resource(
|
|
251
|
+
self,
|
|
252
|
+
name: str,
|
|
253
|
+
file_path: str,
|
|
254
|
+
resource_type: str,
|
|
255
|
+
project_name: str,
|
|
256
|
+
description: Optional[str] = None,
|
|
257
|
+
filename: Optional[str] = None,
|
|
258
|
+
allow_duplicate_name: bool = False
|
|
259
|
+
) -> Dict[str, Any]:
|
|
260
|
+
"""
|
|
261
|
+
Upload a resource file.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
name: Resource name
|
|
265
|
+
file_path: Path to file to upload
|
|
266
|
+
resource_type: Type of resource (urdf, mjcf, etc.)
|
|
267
|
+
project_name: Project name (required)
|
|
268
|
+
description: Optional description
|
|
269
|
+
filename: Optional custom filename
|
|
270
|
+
allow_duplicate_name: Allow creating new version with existing name
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Created resource data
|
|
274
|
+
"""
|
|
275
|
+
with open(file_path, 'rb') as f:
|
|
276
|
+
files = {'file': f}
|
|
277
|
+
data = {}
|
|
278
|
+
if description:
|
|
279
|
+
data['description'] = description
|
|
280
|
+
if filename:
|
|
281
|
+
data['filename'] = filename
|
|
282
|
+
|
|
283
|
+
params = {
|
|
284
|
+
'resource_type': resource_type,
|
|
285
|
+
'project_name': project_name,
|
|
286
|
+
'allow_duplicate_name': allow_duplicate_name
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
response = self.post(f"/resources/{name}/upload", files=files, params=params)
|
|
290
|
+
return response.json()
|
|
291
|
+
|
|
292
|
+
def get_resource_types(self) -> List[str]:
|
|
293
|
+
"""Get list of all available resource types."""
|
|
294
|
+
response = self.get("/resources/types")
|
|
295
|
+
return response.json()
|
|
296
|
+
|
|
297
|
+
def get_scenarios(
|
|
298
|
+
self,
|
|
299
|
+
project_name: str,
|
|
300
|
+
limit: int = 20,
|
|
301
|
+
offset: int = 0,
|
|
302
|
+
include_inactive: bool = False,
|
|
303
|
+
search: Optional[str] = None,
|
|
304
|
+
status_filter: Optional[str] = None
|
|
305
|
+
) -> Dict[str, Any]:
|
|
306
|
+
"""
|
|
307
|
+
Get list of scenarios with pagination and filtering.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
project_name: Project name (required)
|
|
311
|
+
limit: Maximum number of scenarios to return (1-100)
|
|
312
|
+
offset: Number of scenarios to skip
|
|
313
|
+
include_inactive: Include inactive scenarios in results
|
|
314
|
+
search: Search term to filter by name, description, or UUID
|
|
315
|
+
status_filter: Filter by specific status
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Paginated list of scenarios
|
|
319
|
+
"""
|
|
320
|
+
params = {
|
|
321
|
+
"project_name": project_name,
|
|
322
|
+
"skip": offset,
|
|
323
|
+
"limit": limit,
|
|
324
|
+
"include_inactive": include_inactive
|
|
325
|
+
}
|
|
326
|
+
if search:
|
|
327
|
+
params["search"] = search
|
|
328
|
+
if status_filter:
|
|
329
|
+
params["status_filter"] = status_filter
|
|
330
|
+
|
|
331
|
+
response = self.get("/scenarios", params=params)
|
|
332
|
+
return response.json()
|
|
333
|
+
|
|
334
|
+
def get_scenario(self, uuid: str, project_name: str, version: Optional[int] = None) -> Dict[str, Any]:
|
|
335
|
+
"""
|
|
336
|
+
Get a specific scenario.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
uuid: Scenario UUID
|
|
340
|
+
project_name: Project name (required)
|
|
341
|
+
version: Optional specific version (defaults to latest)
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Scenario data
|
|
345
|
+
"""
|
|
346
|
+
endpoint = f"/scenarios/{uuid}"
|
|
347
|
+
params = {"project_name": project_name}
|
|
348
|
+
if version is not None:
|
|
349
|
+
params["scenario_version"] = str(version)
|
|
350
|
+
|
|
351
|
+
response = self.get(endpoint, params=params)
|
|
352
|
+
return response.json()
|
|
353
|
+
|
|
354
|
+
def create_scenario(
|
|
355
|
+
self,
|
|
356
|
+
name: str,
|
|
357
|
+
project_name: str,
|
|
358
|
+
description: Optional[str] = None,
|
|
359
|
+
yaml_content: Optional[str] = None
|
|
360
|
+
) -> Dict[str, Any]:
|
|
361
|
+
"""
|
|
362
|
+
Create a new scenario.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
name: Scenario name
|
|
366
|
+
project_name: Project name to organize the scenario (required)
|
|
367
|
+
description: Optional scenario description
|
|
368
|
+
yaml_content: Optional YAML content defining the scenario
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Created scenario data with UUID and version
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
HTTPException: If scenario name already exists for the project
|
|
375
|
+
"""
|
|
376
|
+
data = {
|
|
377
|
+
"name": name,
|
|
378
|
+
"project_name": project_name
|
|
379
|
+
}
|
|
380
|
+
if description:
|
|
381
|
+
data["description"] = description
|
|
382
|
+
if yaml_content:
|
|
383
|
+
data["yaml_content"] = yaml_content
|
|
384
|
+
|
|
385
|
+
response = self.post("/scenarios", data=data)
|
|
386
|
+
return response.json()
|
|
387
|
+
|
|
388
|
+
# Run CI API methods
|
|
389
|
+
|
|
390
|
+
def create_project(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
|
391
|
+
"""
|
|
392
|
+
Create a new project.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
name: Project name
|
|
396
|
+
description: Optional project description
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Created project data
|
|
400
|
+
"""
|
|
401
|
+
data = {"name": name}
|
|
402
|
+
if description:
|
|
403
|
+
data["description"] = description
|
|
404
|
+
|
|
405
|
+
response = self.post("/projects", data=data)
|
|
406
|
+
return response.json()
|
|
407
|
+
|
|
408
|
+
def get_projects(
|
|
409
|
+
self,
|
|
410
|
+
limit: int = 20,
|
|
411
|
+
offset: int = 0
|
|
412
|
+
) -> Dict[str, Any]:
|
|
413
|
+
"""
|
|
414
|
+
Get list of projects.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
limit: Maximum number of projects to return
|
|
418
|
+
offset: Number of projects to skip
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Project list with pagination info
|
|
422
|
+
"""
|
|
423
|
+
params = {"limit": limit, "offset": offset}
|
|
424
|
+
response = self.get("/projects", params=params)
|
|
425
|
+
return response.json()
|
|
426
|
+
|
|
427
|
+
def get_project(self, name: str) -> Dict[str, Any]:
|
|
428
|
+
"""
|
|
429
|
+
Get a specific project.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
name: Project name
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Project data
|
|
436
|
+
"""
|
|
437
|
+
response = self.get(f"/projects/{name}")
|
|
438
|
+
return response.json()
|
|
439
|
+
|
|
440
|
+
def update_project(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
|
441
|
+
"""
|
|
442
|
+
Update a project.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
name: Project name
|
|
446
|
+
description: Optional new description
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Updated project data
|
|
450
|
+
"""
|
|
451
|
+
data = {}
|
|
452
|
+
if description is not None:
|
|
453
|
+
data["description"] = description
|
|
454
|
+
|
|
455
|
+
response = self.put(f"/projects/{name}", data=data)
|
|
456
|
+
return response.json()
|
|
457
|
+
|
|
458
|
+
def create_run(
|
|
459
|
+
self,
|
|
460
|
+
name: str,
|
|
461
|
+
project_name: str,
|
|
462
|
+
description: Optional[str] = None,
|
|
463
|
+
arguments: Optional[List[Dict[str, str]]] = None,
|
|
464
|
+
tags: Optional[List[str]] = None
|
|
465
|
+
) -> Dict[str, Any]:
|
|
466
|
+
"""
|
|
467
|
+
Create a new run.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
name: Run name
|
|
471
|
+
project_name: Project name
|
|
472
|
+
description: Optional run description
|
|
473
|
+
arguments: Optional list of key-value arguments
|
|
474
|
+
tags: Optional list of tags
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Created run data with runId
|
|
478
|
+
"""
|
|
479
|
+
data = {
|
|
480
|
+
"name": name,
|
|
481
|
+
"project_name": project_name,
|
|
482
|
+
"arguments": arguments or [],
|
|
483
|
+
"tags": tags or [],
|
|
484
|
+
"status": RunStatus.RUNNING.value
|
|
485
|
+
}
|
|
486
|
+
if description:
|
|
487
|
+
data["description"] = description
|
|
488
|
+
|
|
489
|
+
response = self.post("/runs", data=data)
|
|
490
|
+
return response.json()
|
|
491
|
+
|
|
492
|
+
def get_runs(
|
|
493
|
+
self,
|
|
494
|
+
project_name: Optional[str],
|
|
495
|
+
status: Optional[RunStatus] = None,
|
|
496
|
+
tags: Optional[List[str]] = None,
|
|
497
|
+
limit: int = 20,
|
|
498
|
+
offset: int = 0
|
|
499
|
+
) -> Dict[str, Any]:
|
|
500
|
+
"""
|
|
501
|
+
Get list of runs.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
project_name: Filter by project name
|
|
505
|
+
status: Filter by status (running, finished, failed, killed)
|
|
506
|
+
tags: Filter by tags
|
|
507
|
+
limit: Maximum number of runs to return
|
|
508
|
+
offset: Number of runs to skip
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Run list with pagination info
|
|
512
|
+
"""
|
|
513
|
+
params = {"limit": limit, "offset": offset}
|
|
514
|
+
if not project_name:
|
|
515
|
+
raise ValueError("project_name is required to get runs.")
|
|
516
|
+
params["project_name"] = project_name
|
|
517
|
+
if status:
|
|
518
|
+
params["status"] = status.value
|
|
519
|
+
if tags:
|
|
520
|
+
params["tags"] = ",".join(tags)
|
|
521
|
+
|
|
522
|
+
response = self.get("/runs", params=params)
|
|
523
|
+
return response.json()
|
|
524
|
+
|
|
525
|
+
def get_run(self, run_id: str) -> Dict[str, Any]:
|
|
526
|
+
"""
|
|
527
|
+
Get a specific run.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
run_id: Run ID
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Run data
|
|
534
|
+
"""
|
|
535
|
+
response = self.get(f"/runs/{run_id}")
|
|
536
|
+
return response.json()
|
|
537
|
+
|
|
538
|
+
def update_run(
|
|
539
|
+
self,
|
|
540
|
+
run_id: str,
|
|
541
|
+
name: Optional[str] = None,
|
|
542
|
+
description: Optional[str] = None,
|
|
543
|
+
status: Optional[RunStatus] = None,
|
|
544
|
+
err_msg: Optional[str] = None,
|
|
545
|
+
arguments: Optional[List[Dict[str, str]]] = None,
|
|
546
|
+
tags: Optional[List[str]] = None
|
|
547
|
+
) -> Dict[str, Any]:
|
|
548
|
+
"""
|
|
549
|
+
Update a run.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
run_id: Run ID
|
|
553
|
+
name: Optional new name
|
|
554
|
+
description: Optional new description
|
|
555
|
+
status: Optional new status
|
|
556
|
+
err_msg: Optional error message
|
|
557
|
+
arguments: Optional new arguments
|
|
558
|
+
tags: Optional new tags
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Updated run data
|
|
562
|
+
"""
|
|
563
|
+
data = {}
|
|
564
|
+
if name is not None:
|
|
565
|
+
data["name"] = name
|
|
566
|
+
if description is not None:
|
|
567
|
+
data["description"] = description
|
|
568
|
+
if status is not None:
|
|
569
|
+
data["status"] = status.value
|
|
570
|
+
if err_msg is not None:
|
|
571
|
+
data["err_msg"] = err_msg
|
|
572
|
+
if arguments is not None:
|
|
573
|
+
data["arguments"] = arguments
|
|
574
|
+
if tags is not None:
|
|
575
|
+
data["tags"] = tags
|
|
576
|
+
|
|
577
|
+
response = self.put(f"/runs/{run_id}", data=data)
|
|
578
|
+
return response.json()
|
|
579
|
+
|
|
580
|
+
def create_episode(
|
|
581
|
+
self,
|
|
582
|
+
run_id: str,
|
|
583
|
+
episode_id: str,
|
|
584
|
+
status: Optional[EpisodeStatus] = None
|
|
585
|
+
) -> Dict[str, Any]:
|
|
586
|
+
"""
|
|
587
|
+
Create a new episode.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
run_id: Run ID
|
|
591
|
+
episode_id: Episode name
|
|
592
|
+
status: Optional episode status
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Created episode data
|
|
596
|
+
"""
|
|
597
|
+
data = {
|
|
598
|
+
"episode_id": episode_id,
|
|
599
|
+
"run_id": run_id
|
|
600
|
+
}
|
|
601
|
+
if status:
|
|
602
|
+
data["status"] = status.value
|
|
603
|
+
|
|
604
|
+
response = self.post("/episodes", data=data)
|
|
605
|
+
return response.json()
|
|
606
|
+
|
|
607
|
+
def get_episodes(
|
|
608
|
+
self,
|
|
609
|
+
run_id: Optional[str] = None,
|
|
610
|
+
status: Optional[EpisodeStatus] = None,
|
|
611
|
+
limit: int = 20,
|
|
612
|
+
offset: int = 0
|
|
613
|
+
) -> Dict[str, Any]:
|
|
614
|
+
"""
|
|
615
|
+
Get list of episodes.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
run_id: Filter by run ID
|
|
619
|
+
status: Filter by status
|
|
620
|
+
limit: Maximum number of episodes to return
|
|
621
|
+
offset: Number of episodes to skip
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Episode list with pagination info
|
|
625
|
+
"""
|
|
626
|
+
params = {"limit": limit, "offset": offset}
|
|
627
|
+
if run_id:
|
|
628
|
+
params["run_id"] = run_id
|
|
629
|
+
if status:
|
|
630
|
+
params["status"] = status.value
|
|
631
|
+
|
|
632
|
+
response = self.get("/episodes", params=params)
|
|
633
|
+
return response.json()
|
|
634
|
+
|
|
635
|
+
def get_episode(self, run_id: str, episode_id: str) -> Dict[str, Any]:
|
|
636
|
+
"""
|
|
637
|
+
Get a specific episode.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
run_id: Run ID
|
|
641
|
+
episode_id: Episode name
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
Episode data
|
|
645
|
+
"""
|
|
646
|
+
response = self.get(f"/episodes/{run_id}/{episode_id}")
|
|
647
|
+
return response.json()
|
|
648
|
+
|
|
649
|
+
def update_episode(
|
|
650
|
+
self,
|
|
651
|
+
run_id: str,
|
|
652
|
+
episode_id: str,
|
|
653
|
+
status: Optional[EpisodeStatus] = None,
|
|
654
|
+
err_msg: Optional[str] = None
|
|
655
|
+
) -> Dict[str, Any]:
|
|
656
|
+
"""
|
|
657
|
+
Update an episode.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
run_id: Run ID
|
|
661
|
+
episode_id: Episode name
|
|
662
|
+
status: Optional new status
|
|
663
|
+
err_msg: Optional error message
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
Updated episode data
|
|
667
|
+
"""
|
|
668
|
+
data = {}
|
|
669
|
+
if status is not None:
|
|
670
|
+
data["status"] = status.value
|
|
671
|
+
if err_msg is not None:
|
|
672
|
+
data["err_msg"] = err_msg
|
|
673
|
+
response = self.put(f"/episodes/{run_id}/{episode_id}", data=data)
|
|
674
|
+
return response.json()
|
|
675
|
+
|
|
676
|
+
def delete_episode(self, run_id: str, episode_id: str) -> None:
|
|
677
|
+
"""
|
|
678
|
+
Delete an episode.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
run_id: Run ID
|
|
682
|
+
episode_id: Episode name
|
|
683
|
+
"""
|
|
684
|
+
self.delete(f"/episodes/{run_id}/{episode_id}")
|
|
685
|
+
|
|
686
|
+
def upload_blob(
|
|
687
|
+
self,
|
|
688
|
+
artifact_key: str,
|
|
689
|
+
run_id: str,
|
|
690
|
+
artifact_type: str,
|
|
691
|
+
file_content: bytes | None = None,
|
|
692
|
+
file_path: str | None = None,
|
|
693
|
+
episode_id: Optional[str] = None,
|
|
694
|
+
filename: Optional[str] = None,
|
|
695
|
+
content_type: Optional[str] = None
|
|
696
|
+
) -> Dict[str, Any]:
|
|
697
|
+
"""
|
|
698
|
+
Upload a blob artifact (image/video).
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
artifact_key: Artifact key identifier
|
|
702
|
+
run_id: Run ID
|
|
703
|
+
artifact_type: Type of artifact ('image' or 'video')
|
|
704
|
+
file_content: File content as bytes
|
|
705
|
+
file_path: Path to file to upload
|
|
706
|
+
episode_id: Optional episode ID (None for run-level artifacts)
|
|
707
|
+
filename: Optional filename to use for the uploaded file
|
|
708
|
+
content_type: Optional content type (e.g., 'image/png', 'video/mp4')
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Created artifact data
|
|
712
|
+
"""
|
|
713
|
+
form_data = {
|
|
714
|
+
'artifact_key': artifact_key,
|
|
715
|
+
'run_id': run_id,
|
|
716
|
+
'artifact_type': artifact_type
|
|
717
|
+
}
|
|
718
|
+
if episode_id:
|
|
719
|
+
form_data['episode_id'] = episode_id
|
|
720
|
+
if filename:
|
|
721
|
+
form_data['filename'] = filename
|
|
722
|
+
if content_type:
|
|
723
|
+
form_data['content_type'] = content_type
|
|
724
|
+
|
|
725
|
+
if file_path:
|
|
726
|
+
with open(file_path, 'rb') as f:
|
|
727
|
+
files = {'file': f}
|
|
728
|
+
response = self.post("/artifacts/blob/upload", files=files, data=form_data)
|
|
729
|
+
elif file_content:
|
|
730
|
+
files = {'file': ('blob', file_content)}
|
|
731
|
+
response = self.post("/artifacts/blob/upload", files=files, data=form_data)
|
|
732
|
+
else:
|
|
733
|
+
raise ValueError("Either file_path or file_content must be provided for blob upload.")
|
|
734
|
+
return response.json()
|
|
735
|
+
|
|
736
|
+
def upsert_metrics(
|
|
737
|
+
self,
|
|
738
|
+
artifact_key: str,
|
|
739
|
+
run_id: str,
|
|
740
|
+
metric_type: str,
|
|
741
|
+
metric_data: Optional[List[Dict[str, Any]]] = None,
|
|
742
|
+
episode_id: Optional[str] = None
|
|
743
|
+
) -> Dict[str, Any]:
|
|
744
|
+
"""
|
|
745
|
+
Upsert metrics artifact (create or append).
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
artifact_key: Artifact key identifier
|
|
749
|
+
run_id: Run ID
|
|
750
|
+
metric_type: Type of metric display ('line', 'bar', 'scatter', 'gauge', 'counter')
|
|
751
|
+
metric_data: List of metric data points with 'key', 'values', 'timestamp'
|
|
752
|
+
episode_id: Optional episode ID (None for run-level artifacts)
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
Created/updated artifact data
|
|
756
|
+
"""
|
|
757
|
+
data = {
|
|
758
|
+
"artifact_key": artifact_key,
|
|
759
|
+
"run_id": run_id,
|
|
760
|
+
"metric_type": metric_type
|
|
761
|
+
}
|
|
762
|
+
if episode_id:
|
|
763
|
+
data["episode_id"] = episode_id
|
|
764
|
+
if metric_data:
|
|
765
|
+
data["metric_data"] = metric_data
|
|
766
|
+
|
|
767
|
+
response = self.post("/artifacts/metrics", data=data)
|
|
768
|
+
return response.json()
|
|
769
|
+
|
|
770
|
+
def get_artifacts(
|
|
771
|
+
self,
|
|
772
|
+
run_id: Optional[str] = None,
|
|
773
|
+
episode_id: Optional[str] = None,
|
|
774
|
+
artifact_type: Optional[str] = None,
|
|
775
|
+
limit: int = 20,
|
|
776
|
+
offset: int = 0
|
|
777
|
+
) -> Dict[str, Any]:
|
|
778
|
+
"""
|
|
779
|
+
Get list of artifacts.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
run_id: Filter by run ID
|
|
783
|
+
episode_id: Filter by episode ID
|
|
784
|
+
artifact_type: Filter by artifact type
|
|
785
|
+
limit: Maximum number of artifacts to return (0 for no limit)
|
|
786
|
+
offset: Number of artifacts to skip
|
|
787
|
+
|
|
788
|
+
Returns:
|
|
789
|
+
Artifact list with pagination info
|
|
790
|
+
"""
|
|
791
|
+
params = {"limit": limit, "offset": offset}
|
|
792
|
+
if run_id:
|
|
793
|
+
params["run_id"] = run_id
|
|
794
|
+
if episode_id:
|
|
795
|
+
params["episode_id"] = episode_id
|
|
796
|
+
if artifact_type:
|
|
797
|
+
params["artifact_type"] = artifact_type
|
|
798
|
+
|
|
799
|
+
response = self.get("/artifacts", params=params)
|
|
800
|
+
return response.json()
|
|
801
|
+
|
|
802
|
+
def get_artifact(
|
|
803
|
+
self,
|
|
804
|
+
run_id: str,
|
|
805
|
+
episode_id: str,
|
|
806
|
+
artifact_key: str
|
|
807
|
+
) -> Dict[str, Any]:
|
|
808
|
+
"""
|
|
809
|
+
Get a specific artifact.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
run_id: Run ID
|
|
813
|
+
episode_id: Episode ID
|
|
814
|
+
artifact_key: Artifact key
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
Artifact data
|
|
818
|
+
"""
|
|
819
|
+
response = self.get(f"/artifacts/{run_id}/{episode_id}/{artifact_key}")
|
|
820
|
+
return response.json()
|
|
821
|
+
|
|
822
|
+
def upload_code(
|
|
823
|
+
self,
|
|
824
|
+
artifact_key: str,
|
|
825
|
+
run_id: str,
|
|
826
|
+
code_content: str,
|
|
827
|
+
episode_id: Optional[str] = None
|
|
828
|
+
) -> Dict[str, Any]:
|
|
829
|
+
"""
|
|
830
|
+
Upload code artifact (YAML/string content).
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
artifact_key: Artifact key identifier
|
|
834
|
+
run_id: Run ID
|
|
835
|
+
code_content: Code/text content to upload
|
|
836
|
+
episode_id: Optional episode ID (None for run-level artifacts)
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Created artifact data
|
|
840
|
+
"""
|
|
841
|
+
data = {
|
|
842
|
+
'artifact_key': artifact_key,
|
|
843
|
+
'run_id': run_id,
|
|
844
|
+
'code_content': code_content
|
|
845
|
+
}
|
|
846
|
+
if episode_id:
|
|
847
|
+
data['episode_id'] = episode_id
|
|
848
|
+
|
|
849
|
+
response = self.post("/artifacts/code", data=data)
|
|
850
|
+
return response.json()
|
|
851
|
+
|
|
852
|
+
def upload_python(
|
|
853
|
+
self,
|
|
854
|
+
artifact_key: str,
|
|
855
|
+
run_id: str,
|
|
856
|
+
pickled_bytes: bytes,
|
|
857
|
+
episode_id: Optional[str] = None
|
|
858
|
+
) -> Dict[str, Any]:
|
|
859
|
+
"""
|
|
860
|
+
Upload pickled Python object as artifact.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
artifact_key: Artifact key identifier
|
|
864
|
+
run_id: Run ID
|
|
865
|
+
pickled_bytes: Pickled Python object as bytes
|
|
866
|
+
episode_id: Optional episode ID (None for run-level artifacts)
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Created artifact data
|
|
870
|
+
"""
|
|
871
|
+
data = {
|
|
872
|
+
'artifact_key': artifact_key,
|
|
873
|
+
'run_id': run_id
|
|
874
|
+
}
|
|
875
|
+
if episode_id:
|
|
876
|
+
data['episode_id'] = episode_id
|
|
877
|
+
|
|
878
|
+
files = {'file': pickled_bytes}
|
|
879
|
+
response = self.post("/artifacts/python", files=files, data=data)
|
|
880
|
+
return response.json()
|
|
881
|
+
|
|
882
|
+
def upload_scenario_stats_artifact(
|
|
883
|
+
self,
|
|
884
|
+
artifact_key: str,
|
|
885
|
+
run_id: str,
|
|
886
|
+
pickled_bytes: bytes,
|
|
887
|
+
graph_type: str,
|
|
888
|
+
) -> Dict[str, Any]:
|
|
889
|
+
"""
|
|
890
|
+
Upload scenario stats artifact (pickled Python dict data).
|
|
891
|
+
This is an upsert operation - creates if doesn't exist, appends if it does.
|
|
892
|
+
Run-level only (no episode_id support).
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
artifact_key: Artifact key identifier
|
|
896
|
+
run_id: Run ID
|
|
897
|
+
pickled_bytes: Pickled Python dict as bytes containing scenario stats
|
|
898
|
+
graph_type: Graph display type - one of: 'line', 'bar', 'scatter',
|
|
899
|
+
'histogram', 'gaussian', 'heatmap', '3d_map'
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
Created/updated artifact data
|
|
903
|
+
"""
|
|
904
|
+
data = {
|
|
905
|
+
'artifact_key': artifact_key,
|
|
906
|
+
'run_id': run_id,
|
|
907
|
+
'graph_type': graph_type
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
files = {'file': pickled_bytes}
|
|
911
|
+
response = self.post("/artifacts/scenario_stats", files=files, data=data)
|
|
912
|
+
return response.json()
|
|
913
|
+
|
|
914
|
+
def download_artifact(
|
|
915
|
+
self,
|
|
916
|
+
run_id: str,
|
|
917
|
+
episode_id: str,
|
|
918
|
+
artifact_key: str
|
|
919
|
+
) -> bytes:
|
|
920
|
+
"""
|
|
921
|
+
Download a blob artifact file.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
run_id: Run ID
|
|
925
|
+
episode_id: Episode ID
|
|
926
|
+
artifact_key: Artifact key
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
Artifact file content as bytes
|
|
930
|
+
"""
|
|
931
|
+
endpoint = f"/artifacts/{run_id}/{episode_id}/{artifact_key}/download"
|
|
932
|
+
response = self.get(endpoint)
|
|
933
|
+
return response.content
|
|
934
|
+
|
|
935
|
+
def upload_metrics(
|
|
936
|
+
self,
|
|
937
|
+
run_id: str,
|
|
938
|
+
artifact_key: str,
|
|
939
|
+
pickled_bytes: bytes,
|
|
940
|
+
graph_type: str,
|
|
941
|
+
episode_id: str | None = None,
|
|
942
|
+
) -> Dict[str, Any]:
|
|
943
|
+
"""
|
|
944
|
+
Upload metrics artifact.
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
run_id: Run ID
|
|
948
|
+
artifact_key: Artifact key
|
|
949
|
+
pickled_bytes: Pickled metrics data as bytes
|
|
950
|
+
graph_type: Optional new graph type
|
|
951
|
+
episode_id: Optional new episode ID
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
Updated artifact data
|
|
955
|
+
"""
|
|
956
|
+
data = {
|
|
957
|
+
"run_id": run_id,
|
|
958
|
+
"artifact_key": artifact_key,
|
|
959
|
+
'graph_type': graph_type
|
|
960
|
+
}
|
|
961
|
+
files = {'file': pickled_bytes}
|
|
962
|
+
if episode_id:
|
|
963
|
+
data["episode_id"] = episode_id
|
|
964
|
+
|
|
965
|
+
response = self.post("/artifacts/metrics", files=files, data=data)
|
|
966
|
+
return response.json()
|