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.
@@ -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()