wherobots-python-sdk 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.
wherobots/models.py ADDED
@@ -0,0 +1,1080 @@
1
+ """Typed data models for the Wherobots Jobs API.
2
+
3
+ All models use dataclasses with ``from_dict`` / ``to_dict`` helpers
4
+ for JSON round-tripping without requiring pydantic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from wherobots.enums import (
13
+ AppStatus,
14
+ AppType,
15
+ DependencyFileType,
16
+ DependencyType,
17
+ JobStatus,
18
+ )
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Dependency models
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ @dataclass
26
+ class PyPiDependency:
27
+ """A PyPI package dependency."""
28
+
29
+ library_name: str
30
+ library_version: str
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ """Serialize to the Wherobots API JSON format.
34
+
35
+ Returns:
36
+ Dict with ``sourceType``, ``libraryName``, and
37
+ ``libraryVersion``.
38
+ """
39
+ return {
40
+ "sourceType": DependencyType.PYPI.value,
41
+ "libraryName": self.library_name,
42
+ "libraryVersion": self.library_version,
43
+ }
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: dict[str, Any]) -> PyPiDependency:
47
+ """Deserialize from API JSON.
48
+
49
+ Permissive: missing fields default to empty strings. The API's
50
+ own validation surfaces real errors via ``WherobotsAPIError``.
51
+
52
+ Args:
53
+ data: Dictionary containing ``libraryName`` and
54
+ ``libraryVersion``.
55
+
56
+ Returns:
57
+ A new ``PyPiDependency`` instance.
58
+ """
59
+ return cls(
60
+ library_name=data.get("libraryName") or "",
61
+ library_version=data.get("libraryVersion") or "",
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class FileDependency:
67
+ """A file-based dependency (JAR, wheel, zip, etc.)."""
68
+
69
+ file_path: str
70
+ file_type: DependencyFileType | None = None
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """Serialize to the Wherobots API JSON format.
74
+
75
+ Returns:
76
+ Dict with ``sourceType``, ``filePath``, and optionally
77
+ ``fileType``.
78
+ """
79
+ d: dict[str, Any] = {
80
+ "sourceType": DependencyType.FILE.value,
81
+ "filePath": self.file_path,
82
+ }
83
+ if self.file_type is not None:
84
+ d["fileType"] = self.file_type.value
85
+ return d
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict[str, Any]) -> FileDependency:
89
+ """Deserialize from API JSON.
90
+
91
+ Permissive: missing ``filePath`` defaults to empty string;
92
+ unknown ``fileType`` values survive as ``None``.
93
+
94
+ Args:
95
+ data: Dictionary containing ``filePath`` and optionally
96
+ ``fileType``.
97
+
98
+ Returns:
99
+ A new ``FileDependency`` instance.
100
+ """
101
+ file_type = None
102
+ if data.get("fileType") is not None:
103
+ try:
104
+ file_type = DependencyFileType(data["fileType"])
105
+ except ValueError:
106
+ file_type = None
107
+ return cls(
108
+ file_path=data.get("filePath") or "",
109
+ file_type=file_type,
110
+ )
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Run environment / payload models
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ @dataclass
119
+ class RunEnvironment:
120
+ """Environment configuration for a job run."""
121
+
122
+ spark_configs: dict[str, str] | None = None
123
+ dependencies: list[dict[str, Any]] | None = None
124
+ spark_driver_disk_gb: int | None = None
125
+ spark_executor_disk_gb: int | None = None
126
+
127
+ def to_dict(self) -> dict[str, Any]:
128
+ """Serialize to the Wherobots API JSON format.
129
+
130
+ Returns:
131
+ Dict with Spark configs, dependencies, and disk settings.
132
+ Only non-``None`` fields are included.
133
+ """
134
+ d: dict[str, Any] = {}
135
+ if self.spark_configs:
136
+ d["sparkConfigs"] = self.spark_configs
137
+ if self.dependencies:
138
+ d["dependencies"] = self.dependencies
139
+ if self.spark_driver_disk_gb is not None:
140
+ d["sparkDriverDiskGB"] = self.spark_driver_disk_gb
141
+ if self.spark_executor_disk_gb is not None:
142
+ d["sparkExecutorDiskGB"] = self.spark_executor_disk_gb
143
+ return d
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict[str, Any]) -> RunEnvironment:
147
+ """Deserialize from API JSON.
148
+
149
+ Args:
150
+ data: Dictionary with optional ``sparkConfigs``,
151
+ ``dependencies``, ``sparkDriverDiskGB``, and
152
+ ``sparkExecutorDiskGB``.
153
+
154
+ Returns:
155
+ A new ``RunEnvironment`` instance.
156
+ """
157
+ return cls(
158
+ spark_configs=data.get("sparkConfigs"),
159
+ dependencies=data.get("dependencies"),
160
+ spark_driver_disk_gb=data.get("sparkDriverDiskGB"),
161
+ spark_executor_disk_gb=data.get("sparkExecutorDiskGB"),
162
+ )
163
+
164
+
165
+ @dataclass
166
+ class RunPythonPayload:
167
+ """Python script execution payload."""
168
+
169
+ uri: str
170
+ args: list[str] = field(default_factory=list)
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ """Serialize to the Wherobots API JSON format.
174
+
175
+ Returns:
176
+ Dict with ``uri`` and ``args``.
177
+ """
178
+ return {"uri": self.uri, "args": self.args}
179
+
180
+ @classmethod
181
+ def from_dict(cls, data: dict[str, Any]) -> RunPythonPayload:
182
+ """Deserialize from API JSON.
183
+
184
+ Permissive: missing ``uri`` defaults to empty string.
185
+
186
+ Args:
187
+ data: Dictionary containing ``uri`` and optionally ``args``.
188
+
189
+ Returns:
190
+ A new ``RunPythonPayload`` instance.
191
+ """
192
+ return cls(uri=data.get("uri") or "", args=data.get("args") or [])
193
+
194
+
195
+ @dataclass
196
+ class RunJarPayload:
197
+ """JAR execution payload.
198
+
199
+ Note: per the API spec, ``mainClass`` is optional on responses
200
+ (the server may infer it). Kept as a string here with ``""`` as
201
+ the "unset" sentinel so existing callers who read the field
202
+ unconditionally continue to work.
203
+ """
204
+
205
+ uri: str
206
+ main_class: str = ""
207
+ args: list[str] = field(default_factory=list)
208
+
209
+ def to_dict(self) -> dict[str, Any]:
210
+ """Serialize to the Wherobots API JSON format.
211
+
212
+ Returns:
213
+ Dict with ``uri``, ``mainClass``, and ``args``. ``mainClass``
214
+ is omitted when empty so requests don't override server-side
215
+ inference.
216
+ """
217
+ d: dict[str, Any] = {"uri": self.uri, "args": self.args}
218
+ if self.main_class:
219
+ d["mainClass"] = self.main_class
220
+ return d
221
+
222
+ @classmethod
223
+ def from_dict(cls, data: dict[str, Any]) -> RunJarPayload:
224
+ """Deserialize from API JSON.
225
+
226
+ Permissive: all fields default; the API owns field-level
227
+ validation.
228
+
229
+ Args:
230
+ data: Dictionary containing ``uri``, ``mainClass``, and
231
+ optionally ``args``.
232
+
233
+ Returns:
234
+ A new ``RunJarPayload`` instance.
235
+ """
236
+ return cls(
237
+ uri=data.get("uri") or "",
238
+ main_class=data.get("mainClass") or "",
239
+ args=data.get("args") or [],
240
+ )
241
+
242
+
243
+ @dataclass
244
+ class CreateRunPayload:
245
+ """Full payload for ``POST /runs``."""
246
+
247
+ runtime: str
248
+ name: str
249
+ version: str = "latest"
250
+ timeout_seconds: int = 3600
251
+ run_python: RunPythonPayload | None = None
252
+ run_jar: RunJarPayload | None = None
253
+ environment: RunEnvironment | None = None
254
+
255
+ def to_dict(self) -> dict[str, Any]:
256
+ """Serialize to the Wherobots API JSON format.
257
+
258
+ Returns:
259
+ Dict suitable for ``POST /runs`` request body.
260
+ """
261
+ d: dict[str, Any] = {
262
+ "runtime": self.runtime,
263
+ "name": self.name,
264
+ "version": self.version,
265
+ "timeoutSeconds": self.timeout_seconds,
266
+ }
267
+ if self.run_python is not None:
268
+ d["runPython"] = self.run_python.to_dict()
269
+ if self.run_jar is not None:
270
+ d["runJar"] = self.run_jar.to_dict()
271
+ if self.environment is not None:
272
+ env_dict = self.environment.to_dict()
273
+ if env_dict:
274
+ d["environment"] = env_dict
275
+ return d
276
+
277
+ @classmethod
278
+ def from_dict(cls, data: dict[str, Any]) -> CreateRunPayload:
279
+ """Deserialize from API JSON.
280
+
281
+ Args:
282
+ data: Dictionary containing ``runtime``, ``name``, and
283
+ optional nested ``runPython``, ``runJar``, and
284
+ ``environment`` objects.
285
+
286
+ Returns:
287
+ A new ``CreateRunPayload`` instance.
288
+ """
289
+ run_python = None
290
+ if "runPython" in data and data["runPython"] is not None:
291
+ run_python = RunPythonPayload.from_dict(data["runPython"])
292
+ run_jar = None
293
+ if "runJar" in data and data["runJar"] is not None:
294
+ run_jar = RunJarPayload.from_dict(data["runJar"])
295
+ environment = None
296
+ if "environment" in data and data["environment"] is not None:
297
+ environment = RunEnvironment.from_dict(data["environment"])
298
+ return cls(
299
+ runtime=data.get("runtime", ""),
300
+ name=data.get("name", ""),
301
+ version=data.get("version", "latest"),
302
+ timeout_seconds=data.get("timeoutSeconds", 3600),
303
+ run_python=run_python,
304
+ run_jar=run_jar,
305
+ environment=environment,
306
+ )
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Run view / response models
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ @dataclass
315
+ class RunAppMeta:
316
+ """Application metadata attached to a run (OpenAPI ``RunAppMeta``)."""
317
+
318
+ spark_ui_url: str | None = None
319
+ spark_eventlogs_url: str | None = None
320
+ resolved_version: str | None = None
321
+
322
+ @classmethod
323
+ def from_dict(cls, data: dict[str, Any]) -> RunAppMeta:
324
+ """Deserialize from API JSON.
325
+
326
+ Args:
327
+ data: Dictionary with optional ``spark_ui_url``,
328
+ ``spark_eventlogs_url``, and ``resolved_version``.
329
+
330
+ Returns:
331
+ A new ``RunAppMeta`` instance.
332
+ """
333
+ return cls(
334
+ spark_ui_url=data.get("spark_ui_url"),
335
+ spark_eventlogs_url=data.get("spark_eventlogs_url"),
336
+ resolved_version=data.get("resolved_version"),
337
+ )
338
+
339
+ def to_dict(self) -> dict[str, Any]:
340
+ """Serialize to the Wherobots API JSON format.
341
+
342
+ Uses the API's own snake_case keys.
343
+ """
344
+ d: dict[str, Any] = {}
345
+ if self.spark_ui_url is not None:
346
+ d["spark_ui_url"] = self.spark_ui_url
347
+ if self.spark_eventlogs_url is not None:
348
+ d["spark_eventlogs_url"] = self.spark_eventlogs_url
349
+ if self.resolved_version is not None:
350
+ d["resolved_version"] = self.resolved_version
351
+ return d
352
+
353
+
354
+ @dataclass
355
+ class RunEventMeta:
356
+ """Timestamp window attached to a ``KubeAppEvent`` (OpenAPI ``RunEventMeta``)."""
357
+
358
+ start_timestamp: int | None = None
359
+ end_timestamp: int | None = None
360
+
361
+ @classmethod
362
+ def from_dict(cls, data: dict[str, Any]) -> RunEventMeta:
363
+ return cls(
364
+ start_timestamp=data.get("start_timestamp"),
365
+ end_timestamp=data.get("end_timestamp"),
366
+ )
367
+
368
+ def to_dict(self) -> dict[str, Any]:
369
+ d: dict[str, Any] = {}
370
+ if self.start_timestamp is not None:
371
+ d["start_timestamp"] = self.start_timestamp
372
+ if self.end_timestamp is not None:
373
+ d["end_timestamp"] = self.end_timestamp
374
+ return d
375
+
376
+
377
+ @dataclass
378
+ class KubeAppEvent:
379
+ """Kubernetes application event (OpenAPI ``KubeAppEvent``)."""
380
+
381
+ id: str = ""
382
+ create_time: str = ""
383
+ update_time: str = ""
384
+ code: str | None = None
385
+ message: str | None = None
386
+ event_meta: RunEventMeta | None = None
387
+
388
+ @classmethod
389
+ def from_dict(cls, data: dict[str, Any]) -> KubeAppEvent:
390
+ event_meta = None
391
+ if data.get("eventMeta") is not None:
392
+ event_meta = RunEventMeta.from_dict(data["eventMeta"])
393
+ return cls(
394
+ id=data.get("id") or "",
395
+ create_time=data.get("createTime") or "",
396
+ update_time=data.get("updateTime") or "",
397
+ code=data.get("code"),
398
+ message=data.get("message"),
399
+ event_meta=event_meta,
400
+ )
401
+
402
+ def to_dict(self) -> dict[str, Any]:
403
+ d: dict[str, Any] = {
404
+ "id": self.id,
405
+ "createTime": self.create_time,
406
+ "updateTime": self.update_time,
407
+ }
408
+ if self.code is not None:
409
+ d["code"] = self.code
410
+ if self.message is not None:
411
+ d["message"] = self.message
412
+ if self.event_meta is not None:
413
+ d["eventMeta"] = self.event_meta.to_dict()
414
+ return d
415
+
416
+
417
+ @dataclass
418
+ class RunKubeApp:
419
+ """Kubernetes application details for a run (OpenAPI ``RunKubeApp``).
420
+
421
+ Forward-compat: ``app_type`` and ``status`` round-trip unknown
422
+ server values as raw strings rather than silently dropping them.
423
+ """
424
+
425
+ id: str = ""
426
+ create_time: str = ""
427
+ update_time: str = ""
428
+ instance_id: str = ""
429
+ app_type: AppType | str | None = None
430
+ status: AppStatus | str | None = None
431
+ created_by_id: str | None = None
432
+ api_payload: dict[str, Any] | None = None
433
+ app_meta: RunAppMeta | None = None
434
+ events: list[KubeAppEvent] = field(default_factory=list)
435
+ cpu_usage: int = 0
436
+ region_name: str = ""
437
+ message: str | None = None
438
+
439
+ @classmethod
440
+ def from_dict(cls, data: dict[str, Any]) -> RunKubeApp:
441
+ app_type: AppType | str | None = None
442
+ raw_app_type = data.get("appType")
443
+ if raw_app_type:
444
+ try:
445
+ app_type = AppType(raw_app_type)
446
+ except ValueError:
447
+ app_type = raw_app_type
448
+
449
+ status: AppStatus | str | None = None
450
+ raw_status = data.get("status")
451
+ if raw_status:
452
+ try:
453
+ status = AppStatus(raw_status)
454
+ except ValueError:
455
+ status = raw_status
456
+
457
+ app_meta = None
458
+ if data.get("appMeta") is not None:
459
+ app_meta = RunAppMeta.from_dict(data["appMeta"])
460
+
461
+ events = [KubeAppEvent.from_dict(e) for e in (data.get("events") or [])]
462
+
463
+ return cls(
464
+ id=data.get("id") or "",
465
+ create_time=data.get("createTime") or "",
466
+ update_time=data.get("updateTime") or "",
467
+ instance_id=data.get("instanceId") or "",
468
+ app_type=app_type,
469
+ status=status,
470
+ created_by_id=data.get("createdById"),
471
+ api_payload=data.get("apiPayload"),
472
+ app_meta=app_meta,
473
+ events=events,
474
+ cpu_usage=data.get("cpuUsage") or 0,
475
+ region_name=data.get("regionName") or "",
476
+ message=data.get("message"),
477
+ )
478
+
479
+ def to_dict(self) -> dict[str, Any]:
480
+ d: dict[str, Any] = {
481
+ "id": self.id,
482
+ "createTime": self.create_time,
483
+ "updateTime": self.update_time,
484
+ "instanceId": self.instance_id,
485
+ "cpuUsage": self.cpu_usage,
486
+ "regionName": self.region_name,
487
+ }
488
+ if isinstance(self.app_type, AppType):
489
+ d["appType"] = self.app_type.value
490
+ elif isinstance(self.app_type, str):
491
+ d["appType"] = self.app_type
492
+ if isinstance(self.status, AppStatus):
493
+ d["status"] = self.status.value
494
+ elif isinstance(self.status, str):
495
+ d["status"] = self.status
496
+ if self.created_by_id is not None:
497
+ d["createdById"] = self.created_by_id
498
+ if self.api_payload is not None:
499
+ d["apiPayload"] = self.api_payload
500
+ if self.app_meta is not None:
501
+ d["appMeta"] = self.app_meta.to_dict()
502
+ if self.events:
503
+ d["events"] = [e.to_dict() for e in self.events]
504
+ if self.message is not None:
505
+ d["message"] = self.message
506
+ return d
507
+
508
+
509
+ @dataclass
510
+ class OrganizationCustomer:
511
+ """A user/customer in the organization (OpenAPI ``OrganizationCustomer``).
512
+
513
+ Returned on ``RunView.triggeredBy`` to identify the user who
514
+ submitted a run.
515
+ """
516
+
517
+ id: str = ""
518
+ create_time: str = ""
519
+ update_time: str = ""
520
+ email: str | None = None
521
+ name: str | None = None
522
+ roles: list[str] = field(default_factory=list)
523
+
524
+ @classmethod
525
+ def from_dict(cls, data: dict[str, Any]) -> OrganizationCustomer:
526
+ return cls(
527
+ id=data.get("id") or "",
528
+ create_time=data.get("createTime") or "",
529
+ update_time=data.get("updateTime") or "",
530
+ email=data.get("email"),
531
+ name=data.get("name"),
532
+ roles=list(data.get("roles") or []),
533
+ )
534
+
535
+ def to_dict(self) -> dict[str, Any]:
536
+ d: dict[str, Any] = {
537
+ "id": self.id,
538
+ "createTime": self.create_time,
539
+ "updateTime": self.update_time,
540
+ "roles": list(self.roles),
541
+ }
542
+ if self.email is not None:
543
+ d["email"] = self.email
544
+ if self.name is not None:
545
+ d["name"] = self.name
546
+ return d
547
+
548
+
549
+ @dataclass
550
+ class RunView:
551
+ """Complete view of a job run (``GET /runs/{id}`` response).
552
+
553
+ The Wherobots API nests payload fields (``runtime``, ``runPython``,
554
+ ``runJar``, ``timeoutSeconds``, ``environment``) under a ``"payload"``
555
+ key. ``from_dict`` transparently flattens these so callers can access
556
+ e.g. ``view.runtime`` directly.
557
+
558
+ Timestamp fields from the API use ``createTime`` / ``startTime`` /
559
+ ``completeTime``; for convenience the parser also accepts the legacy
560
+ ``createdAt`` / ``startedAt`` / ``completedAt`` aliases.
561
+
562
+ Forward-compat:
563
+
564
+ * ``status`` is typed as ``JobStatus | str | None`` so a status value
565
+ the SDK doesn't know about yet (e.g. a new terminal state added
566
+ server-side) round-trips as the raw string instead of silently
567
+ disappearing. Use :func:`is_terminal_status` to check terminality
568
+ without assuming the enum.
569
+ * ``raw`` holds the full server response dict, so newly added fields
570
+ are always reachable even when the typed surface hasn't caught up.
571
+ """
572
+
573
+ id: str
574
+ name: str
575
+ status: JobStatus | str | None = None
576
+ runtime: str | None = None
577
+ region: str | None = None
578
+ version: str | None = None
579
+ timeout_seconds: int | None = None
580
+ create_time: str | None = None
581
+ update_time: str | None = None
582
+ start_time: str | None = None
583
+ complete_time: str | None = None
584
+ run_python: RunPythonPayload | None = None
585
+ run_jar: RunJarPayload | None = None
586
+ environment: RunEnvironment | None = None
587
+ app_meta: RunAppMeta | None = None
588
+ kube_app: RunKubeApp | None = None
589
+ triggered_by: OrganizationCustomer | None = None
590
+ extra: dict[str, Any] = field(default_factory=dict)
591
+ raw: dict[str, Any] = field(default_factory=dict)
592
+
593
+ # Legacy aliases ---------------------------------------------------
594
+ @property
595
+ def created_at(self) -> str | None:
596
+ """Alias kept for backward compatibility."""
597
+ return self.create_time
598
+
599
+ @property
600
+ def started_at(self) -> str | None:
601
+ """Alias kept for backward compatibility."""
602
+ return self.start_time
603
+
604
+ @property
605
+ def completed_at(self) -> str | None:
606
+ """Alias kept for backward compatibility."""
607
+ return self.complete_time
608
+
609
+ @classmethod
610
+ def from_dict(cls, data: dict[str, Any]) -> RunView:
611
+ """Deserialize from API JSON, flattening the nested ``payload``.
612
+
613
+ Args:
614
+ data: Full API response dictionary for a single run.
615
+
616
+ Returns:
617
+ A new ``RunView`` instance with payload fields promoted to
618
+ top-level attributes.
619
+ """
620
+ # The API nests payload fields under "payload"; flatten them.
621
+ payload: dict[str, Any] = data.get("payload") or {}
622
+
623
+ status: JobStatus | str | None = None
624
+ raw_status = data.get("status")
625
+ if raw_status:
626
+ try:
627
+ status = JobStatus(raw_status)
628
+ except ValueError:
629
+ # Preserve unknown status strings verbatim so callers
630
+ # can still see the value and react (e.g. log it).
631
+ status = raw_status
632
+
633
+ # Helper: look in payload first, then top-level (for flexibility).
634
+ def _get(key: str, default: Any = None) -> Any:
635
+ if key in payload:
636
+ return payload[key]
637
+ return data.get(key, default)
638
+
639
+ run_python = None
640
+ rp = _get("runPython")
641
+ if rp is not None:
642
+ run_python = RunPythonPayload.from_dict(rp)
643
+
644
+ run_jar = None
645
+ rj = _get("runJar")
646
+ if rj is not None:
647
+ run_jar = RunJarPayload.from_dict(rj)
648
+
649
+ environment = None
650
+ env = _get("environment")
651
+ if env is not None:
652
+ environment = RunEnvironment.from_dict(env)
653
+
654
+ app_meta = None
655
+ # appMeta may live inside kubeApp or at top level
656
+ am = data.get("appMeta")
657
+ if am is not None:
658
+ app_meta = RunAppMeta.from_dict(am)
659
+
660
+ kube_app = None
661
+ ka = data.get("kubeApp")
662
+ if ka is not None:
663
+ kube_app = RunKubeApp.from_dict(ka)
664
+ # appMeta can also be nested in kubeApp
665
+ if app_meta is None and ka.get("appMeta") is not None:
666
+ app_meta = RunAppMeta.from_dict(ka["appMeta"])
667
+
668
+ triggered_by = None
669
+ if data.get("triggeredBy") is not None:
670
+ triggered_by = OrganizationCustomer.from_dict(data["triggeredBy"])
671
+
672
+ known_keys = {
673
+ "id",
674
+ "name",
675
+ "status",
676
+ "runtime",
677
+ "region",
678
+ "version",
679
+ "timeoutSeconds",
680
+ "payload",
681
+ # API timestamp keys
682
+ "createTime",
683
+ "updateTime",
684
+ "startTime",
685
+ "completeTime",
686
+ # Legacy timestamp keys
687
+ "createdAt",
688
+ "startedAt",
689
+ "completedAt",
690
+ "runPython",
691
+ "runJar",
692
+ "environment",
693
+ "appMeta",
694
+ "kubeApp",
695
+ "triggeredBy",
696
+ }
697
+ extra = {k: v for k, v in data.items() if k not in known_keys}
698
+
699
+ return cls(
700
+ id=data.get("id", ""),
701
+ name=data.get("name") or payload.get("name", ""),
702
+ status=status,
703
+ runtime=_get("runtime"),
704
+ region=data.get("region"),
705
+ version=_get("version"),
706
+ timeout_seconds=_get("timeoutSeconds"),
707
+ create_time=data.get("createTime", data.get("createdAt")),
708
+ update_time=data.get("updateTime"),
709
+ start_time=data.get("startTime", data.get("startedAt")),
710
+ complete_time=data.get("completeTime", data.get("completedAt")),
711
+ run_python=run_python,
712
+ run_jar=run_jar,
713
+ environment=environment,
714
+ app_meta=app_meta,
715
+ kube_app=kube_app,
716
+ triggered_by=triggered_by,
717
+ extra=extra,
718
+ raw=dict(data),
719
+ )
720
+
721
+ def to_dict(self) -> dict[str, Any]:
722
+ """Serialize to the Wherobots API JSON format.
723
+
724
+ Returns:
725
+ Dict with all run fields. Only non-``None`` fields are
726
+ included. Extra (unknown) fields are merged at the top
727
+ level.
728
+ """
729
+ d: dict[str, Any] = {"id": self.id, "name": self.name}
730
+ if isinstance(self.status, JobStatus):
731
+ d["status"] = self.status.value
732
+ elif isinstance(self.status, str):
733
+ d["status"] = self.status
734
+ if self.runtime is not None:
735
+ d["runtime"] = self.runtime
736
+ if self.region is not None:
737
+ d["region"] = self.region
738
+ if self.version is not None:
739
+ d["version"] = self.version
740
+ if self.timeout_seconds is not None:
741
+ d["timeoutSeconds"] = self.timeout_seconds
742
+ if self.create_time is not None:
743
+ d["createTime"] = self.create_time
744
+ if self.update_time is not None:
745
+ d["updateTime"] = self.update_time
746
+ if self.start_time is not None:
747
+ d["startTime"] = self.start_time
748
+ if self.complete_time is not None:
749
+ d["completeTime"] = self.complete_time
750
+ if self.run_python is not None:
751
+ d["runPython"] = self.run_python.to_dict()
752
+ if self.run_jar is not None:
753
+ d["runJar"] = self.run_jar.to_dict()
754
+ if self.environment is not None:
755
+ d["environment"] = self.environment.to_dict()
756
+ if self.app_meta is not None:
757
+ d["appMeta"] = self.app_meta.to_dict()
758
+ if self.kube_app is not None:
759
+ d["kubeApp"] = self.kube_app.to_dict()
760
+ if self.triggered_by is not None:
761
+ d["triggeredBy"] = self.triggered_by.to_dict()
762
+ if self.extra:
763
+ d.update(self.extra)
764
+ return d
765
+
766
+
767
+ # ---------------------------------------------------------------------------
768
+ # Logs models
769
+ # ---------------------------------------------------------------------------
770
+
771
+
772
+ @dataclass
773
+ class LogItem:
774
+ """A single log entry.
775
+
776
+ The ``timestamp`` field is typed as ``Union[int, str, None]`` because
777
+ the Wherobots API returns integer epoch values (e.g. ``0``) while
778
+ other sources may use ISO-8601 strings.
779
+ """
780
+
781
+ raw: str = ""
782
+ timestamp: int | str | None = None
783
+ level: str | None = None
784
+ message: str | None = None
785
+ extra: dict[str, Any] = field(default_factory=dict)
786
+
787
+ @classmethod
788
+ def from_dict(cls, data: dict[str, Any]) -> LogItem:
789
+ """Deserialize from API JSON.
790
+
791
+ Args:
792
+ data: Dictionary with ``raw`` and optional ``timestamp``,
793
+ ``level``, ``message`` fields. Unknown keys are
794
+ captured in ``extra``.
795
+
796
+ Returns:
797
+ A new ``LogItem`` instance.
798
+ """
799
+ known_keys = {"raw", "timestamp", "level", "message"}
800
+ extra = {k: v for k, v in data.items() if k not in known_keys}
801
+ return cls(
802
+ raw=data.get("raw", ""),
803
+ timestamp=data.get("timestamp"),
804
+ level=data.get("level"),
805
+ message=data.get("message"),
806
+ extra=extra,
807
+ )
808
+
809
+ def to_dict(self) -> dict[str, Any]:
810
+ """Serialize to the Wherobots API JSON format.
811
+
812
+ Returns:
813
+ Dict with log entry fields. Only non-``None`` fields are
814
+ included. Extra fields are merged at the top level.
815
+ """
816
+ d: dict[str, Any] = {"raw": self.raw}
817
+ if self.timestamp is not None:
818
+ d["timestamp"] = self.timestamp
819
+ if self.level is not None:
820
+ d["level"] = self.level
821
+ if self.message is not None:
822
+ d["message"] = self.message
823
+ if self.extra:
824
+ d.update(self.extra)
825
+ return d
826
+
827
+
828
+ @dataclass
829
+ class LogsResponse:
830
+ """Response from ``GET /runs/{id}/logs``.
831
+
832
+ The API returns ``current_page`` (int) and ``next_page`` (int) for
833
+ cursor-based pagination. ``raw`` holds the original server response
834
+ so callers can reach new fields without a SDK release.
835
+ """
836
+
837
+ items: list[LogItem] = field(default_factory=list)
838
+ next_page: int | str | None = None
839
+ current_page: int | str | None = None
840
+ raw: dict[str, Any] = field(default_factory=dict)
841
+
842
+ @classmethod
843
+ def from_dict(cls, data: dict[str, Any]) -> LogsResponse:
844
+ """Deserialize from API JSON.
845
+
846
+ Args:
847
+ data: Dictionary with ``items`` list and optional
848
+ ``next_page`` / ``current_page`` cursors. Accepts
849
+ both snake_case and camelCase key variants.
850
+
851
+ Returns:
852
+ A new ``LogsResponse`` instance.
853
+ """
854
+ raw_items = data.get("items")
855
+ items = [LogItem.from_dict(i) for i in (raw_items or [])]
856
+ next_page = data.get("next_page", data.get("nextPage"))
857
+ current_page = data.get("current_page", data.get("currentPage"))
858
+ return cls(
859
+ items=items,
860
+ next_page=next_page,
861
+ current_page=current_page,
862
+ raw=dict(data),
863
+ )
864
+
865
+ def to_dict(self) -> dict[str, Any]:
866
+ """Serialize to the Wherobots API JSON format.
867
+
868
+ Returns:
869
+ Dict with serialized ``items`` and pagination cursors.
870
+ """
871
+ d: dict[str, Any] = {"items": [i.to_dict() for i in self.items]}
872
+ if self.next_page is not None:
873
+ d["next_page"] = self.next_page
874
+ if self.current_page is not None:
875
+ d["current_page"] = self.current_page
876
+ return d
877
+
878
+
879
+ # ---------------------------------------------------------------------------
880
+ # Metrics models
881
+ # ---------------------------------------------------------------------------
882
+ #
883
+ # The OpenAPI spec types the metrics blobs as plain ``object`` with
884
+ # no fixed per-metric shape, so the SDK intentionally does not expose
885
+ # typed ``SeriesMetric`` / ``InstantMetric`` / ``MetricDetail`` /
886
+ # ``DataValue`` dataclasses. Use ``RunMetricsResponse.series_metrics`` /
887
+ # ``instant_metrics`` (keyed by metric name) for ergonomic lookup and
888
+ # ``RunMetricsResponse.raw`` to see exactly what the server returned.
889
+
890
+
891
+ @dataclass
892
+ class RunMetricsResponse:
893
+ """Response from ``GET /runs/{id}/metrics``.
894
+
895
+ Per the OpenAPI spec the metrics blobs use snake_case keys
896
+ (``series_metrics`` / ``instant_metrics``) and are typed as plain
897
+ ``object`` (the server's internal metric shape is not stable enough
898
+ to type against). The SDK exposes them as untyped dicts keyed by
899
+ metric name; callers can inspect ``raw`` for any new fields the
900
+ server adds.
901
+ """
902
+
903
+ series_metrics: dict[str, Any] = field(default_factory=dict)
904
+ instant_metrics: dict[str, Any] = field(default_factory=dict)
905
+ extra: dict[str, Any] = field(default_factory=dict)
906
+ raw: dict[str, Any] = field(default_factory=dict)
907
+
908
+ @classmethod
909
+ def from_dict(cls, data: dict[str, Any]) -> RunMetricsResponse:
910
+ """Deserialize from API JSON.
911
+
912
+ Args:
913
+ data: Dictionary with ``series_metrics`` / ``instant_metrics``
914
+ (also accepts camelCase variants for robustness).
915
+ Unknown keys are captured in ``extra``.
916
+
917
+ Returns:
918
+ A new ``RunMetricsResponse`` instance.
919
+ """
920
+ known_keys = {"series_metrics", "seriesMetrics", "instant_metrics", "instantMetrics"}
921
+ extra = {k: v for k, v in data.items() if k not in known_keys}
922
+ raw_series = data.get("series_metrics", data.get("seriesMetrics", {})) or {}
923
+ raw_instant = data.get("instant_metrics", data.get("instantMetrics", {})) or {}
924
+ return cls(
925
+ series_metrics=dict(raw_series) if isinstance(raw_series, dict) else {},
926
+ instant_metrics=dict(raw_instant) if isinstance(raw_instant, dict) else {},
927
+ extra=extra,
928
+ raw=dict(data),
929
+ )
930
+
931
+ def to_dict(self) -> dict[str, Any]:
932
+ """Serialize to the Wherobots API JSON format.
933
+
934
+ Uses the snake_case keys the API itself uses.
935
+ """
936
+ d: dict[str, Any] = {
937
+ "series_metrics": dict(self.series_metrics),
938
+ "instant_metrics": dict(self.instant_metrics),
939
+ }
940
+ if self.extra:
941
+ d.update(self.extra)
942
+ return d
943
+
944
+
945
+ # ---------------------------------------------------------------------------
946
+ # Pagination
947
+ # ---------------------------------------------------------------------------
948
+
949
+
950
+ @dataclass
951
+ class RunListPage:
952
+ """Paginated list of runs (``GET /runs`` response).
953
+
954
+ The Wherobots API returns string-based pagination cursors:
955
+ ``next_page``, ``previous_page``, ``current_page``,
956
+ ``current_page_backwards``.
957
+ """
958
+
959
+ items: list[RunView] = field(default_factory=list)
960
+ total: int = 0
961
+ next_page: str | None = None
962
+ previous_page: str | None = None
963
+ current_page: str | None = None
964
+ current_page_backwards: str | None = None
965
+ raw: dict[str, Any] = field(default_factory=dict)
966
+
967
+ # Legacy alias for backward compatibility
968
+ @property
969
+ def cursor(self) -> str | None:
970
+ """Alias for ``next_page`` — kept for backward compatibility."""
971
+ return self.next_page
972
+
973
+ @classmethod
974
+ def from_dict(cls, data: dict[str, Any]) -> RunListPage:
975
+ """Deserialize from API JSON.
976
+
977
+ Args:
978
+ data: Dictionary with ``items`` list, ``total`` count, and
979
+ optional pagination cursors. Also accepts legacy
980
+ ``cursor`` key as fallback for ``next_page``.
981
+
982
+ Returns:
983
+ A new ``RunListPage`` instance.
984
+ """
985
+ raw_items = data.get("items")
986
+ items = [RunView.from_dict(i) for i in (raw_items or [])]
987
+ return cls(
988
+ items=items,
989
+ total=data.get("total", len(items)),
990
+ next_page=data.get("next_page", data.get("cursor")),
991
+ previous_page=data.get("previous_page"),
992
+ current_page=data.get("current_page"),
993
+ current_page_backwards=data.get("current_page_backwards"),
994
+ raw=dict(data),
995
+ )
996
+
997
+ def to_dict(self) -> dict[str, Any]:
998
+ """Serialize to the Wherobots API JSON format.
999
+
1000
+ Returns:
1001
+ Dict with serialized ``items``, ``total``, and pagination
1002
+ cursors.
1003
+ """
1004
+ d: dict[str, Any] = {
1005
+ "items": [i.to_dict() for i in self.items],
1006
+ "total": self.total,
1007
+ }
1008
+ if self.next_page is not None:
1009
+ d["next_page"] = self.next_page
1010
+ if self.previous_page is not None:
1011
+ d["previous_page"] = self.previous_page
1012
+ if self.current_page is not None:
1013
+ d["current_page"] = self.current_page
1014
+ if self.current_page_backwards is not None:
1015
+ d["current_page_backwards"] = self.current_page_backwards
1016
+ return d
1017
+
1018
+
1019
+ # =========================================================================== #
1020
+ # Storage Integration Models
1021
+ # =========================================================================== #
1022
+
1023
+
1024
+ @dataclass
1025
+ class StorageIntegration:
1026
+ """A user-configured S3 storage integration.
1027
+
1028
+ Represents a connection between the Wherobots organization and an
1029
+ external S3 bucket, as returned by ``GET /storage``.
1030
+ """
1031
+
1032
+ id: str
1033
+ name: str
1034
+ path: str
1035
+ region: str
1036
+ create_time: str | None = None
1037
+ update_time: str | None = None
1038
+ role_arn: str | None = None
1039
+ raw: dict[str, Any] = field(default_factory=dict)
1040
+
1041
+ @classmethod
1042
+ def from_dict(cls, data: dict[str, Any]) -> StorageIntegration:
1043
+ """Deserialize from the Wherobots API JSON format.
1044
+
1045
+ Args:
1046
+ data: Dictionary from the API response.
1047
+
1048
+ Returns:
1049
+ A new ``StorageIntegration`` instance.
1050
+ """
1051
+ return cls(
1052
+ id=data.get("id", ""),
1053
+ name=data.get("name", ""),
1054
+ path=data.get("path", ""),
1055
+ region=data.get("region", ""),
1056
+ create_time=data.get("createTime"),
1057
+ update_time=data.get("updateTime"),
1058
+ role_arn=data.get("roleArn"),
1059
+ raw=dict(data),
1060
+ )
1061
+
1062
+ def to_dict(self) -> dict[str, Any]:
1063
+ """Serialize to the Wherobots API JSON format.
1064
+
1065
+ Returns:
1066
+ Dict with camelCase keys matching the API contract.
1067
+ """
1068
+ d: dict[str, Any] = {
1069
+ "id": self.id,
1070
+ "name": self.name,
1071
+ "path": self.path,
1072
+ "region": self.region,
1073
+ }
1074
+ if self.create_time is not None:
1075
+ d["createTime"] = self.create_time
1076
+ if self.update_time is not None:
1077
+ d["updateTime"] = self.update_time
1078
+ if self.role_arn is not None:
1079
+ d["roleArn"] = self.role_arn
1080
+ return d