flyte 0.2.0b25__py3-none-any.whl → 0.2.0b27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flyte might be problematic. Click here for more details.

@@ -0,0 +1,698 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections import UserDict
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Any, AsyncGenerator, AsyncIterator, Dict, Iterator, List, Literal, Tuple, Union, cast
8
+
9
+ import grpc
10
+ import rich.pretty
11
+ import rich.repr
12
+ from google.protobuf import timestamp
13
+ from rich.console import Console
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
15
+
16
+ from flyte import types
17
+ from flyte._initialize import ensure_client, get_client, get_common_config
18
+ from flyte._protos.common import list_pb2
19
+ from flyte._protos.workflow import run_definition_pb2, run_service_pb2
20
+ from flyte._protos.workflow.run_service_pb2 import WatchActionDetailsResponse
21
+ from flyte.remote._logs import Logs
22
+ from flyte.syncify import syncify
23
+
24
+ WaitFor = Literal["terminal", "running", "logs-ready"]
25
+
26
+
27
+ def _action_time_phase(action: run_definition_pb2.Action | run_definition_pb2.ActionDetails) -> rich.repr.Result:
28
+ """
29
+ Rich representation of the action time and phase.
30
+ """
31
+ start_time = timestamp.to_datetime(action.status.start_time, timezone.utc)
32
+ yield "start_time", start_time.isoformat()
33
+ if action.status.phase in [
34
+ run_definition_pb2.PHASE_FAILED,
35
+ run_definition_pb2.PHASE_SUCCEEDED,
36
+ run_definition_pb2.PHASE_ABORTED,
37
+ run_definition_pb2.PHASE_TIMED_OUT,
38
+ ]:
39
+ end_time = timestamp.to_datetime(action.status.end_time, timezone.utc)
40
+ yield "end_time", end_time.isoformat()
41
+ yield "run_time", f"{(end_time - start_time).seconds} secs"
42
+ else:
43
+ yield "end_time", None
44
+ yield "run_time", f"{(datetime.now(timezone.utc) - start_time).seconds} secs"
45
+ yield "phase", run_definition_pb2.Phase.Name(action.status.phase)
46
+ if isinstance(action, run_definition_pb2.ActionDetails):
47
+ yield (
48
+ "error",
49
+ f"{action.error_info.kind}: {action.error_info.message}" if action.HasField("error_info") else "NA",
50
+ )
51
+
52
+
53
+ def _action_rich_repr(action: run_definition_pb2.Action) -> rich.repr.Result:
54
+ """
55
+ Rich representation of the action.
56
+ """
57
+ yield "run", action.id.run.name
58
+ if action.metadata.HasField("task"):
59
+ yield "task", action.metadata.task.id.name
60
+ yield "type", "task"
61
+ yield "name", action.id.name
62
+ yield from _action_time_phase(action)
63
+ yield "group", action.metadata.group
64
+ yield "parent", action.metadata.parent
65
+ yield "attempts", action.status.attempts
66
+
67
+
68
+ def _attempt_rich_repr(action: List[run_definition_pb2.ActionAttempt]) -> rich.repr.Result:
69
+ for attempt in action:
70
+ yield "attempt", attempt.attempt
71
+ yield "phase", run_definition_pb2.Phase.Name(attempt.phase)
72
+ yield "logs_available", attempt.logs_available
73
+
74
+
75
+ def _action_details_rich_repr(action: run_definition_pb2.ActionDetails) -> rich.repr.Result:
76
+ """
77
+ Rich representation of the action details.
78
+ """
79
+ yield "name", action.id.run.name
80
+ yield from _action_time_phase(action)
81
+ yield "task", action.resolved_task_spec.task_template.id.name
82
+ yield "task_type", action.resolved_task_spec.task_template.type
83
+ yield "task_version", action.resolved_task_spec.task_template.id.version
84
+ yield "attempts", action.attempts
85
+ yield "error", f"{action.error_info.kind}: {action.error_info.message}" if action.HasField("error_info") else "NA"
86
+ yield "phase", run_definition_pb2.Phase.Name(action.status.phase)
87
+ yield "group", action.metadata.group
88
+ yield "parent", action.metadata.parent
89
+
90
+
91
+ def _action_done_check(phase: run_definition_pb2.Phase) -> bool:
92
+ """
93
+ Check if the action is done.
94
+ """
95
+ return phase in [
96
+ run_definition_pb2.PHASE_FAILED,
97
+ run_definition_pb2.PHASE_SUCCEEDED,
98
+ run_definition_pb2.PHASE_ABORTED,
99
+ run_definition_pb2.PHASE_TIMED_OUT,
100
+ ]
101
+
102
+
103
+ @dataclass
104
+ class Action:
105
+ """
106
+ A class representing an action. It is used to manage the run of a task and its state on the remote Union API.
107
+ """
108
+
109
+ pb2: run_definition_pb2.Action
110
+ _details: ActionDetails | None = None
111
+
112
+ @syncify
113
+ @classmethod
114
+ async def listall(
115
+ cls,
116
+ for_run_name: str,
117
+ filters: str | None = None,
118
+ sort_by: Tuple[str, Literal["asc", "desc"]] | None = None,
119
+ ) -> Union[Iterator[Action], AsyncIterator[Action]]:
120
+ """
121
+ Get all actions for a given run.
122
+
123
+ :param for_run_name: The name of the run.
124
+ :param filters: The filters to apply to the project list.
125
+ :param sort_by: The sorting criteria for the project list, in the format (field, order).
126
+ :return: An iterator of projects.
127
+ """
128
+ ensure_client()
129
+ token = None
130
+ sort_by = sort_by or ("created_at", "asc")
131
+ sort_pb2 = list_pb2.Sort(
132
+ key=sort_by[0], direction=list_pb2.Sort.ASCENDING if sort_by[1] == "asc" else list_pb2.Sort.DESCENDING
133
+ )
134
+ cfg = get_common_config()
135
+ while True:
136
+ req = list_pb2.ListRequest(
137
+ limit=100,
138
+ token=token,
139
+ sort_by=sort_pb2,
140
+ )
141
+ resp = await get_client().run_service.ListActions(
142
+ run_service_pb2.ListActionsRequest(
143
+ request=req,
144
+ run_id=run_definition_pb2.RunIdentifier(
145
+ org=cfg.org,
146
+ project=cfg.project,
147
+ domain=cfg.domain,
148
+ name=for_run_name,
149
+ ),
150
+ )
151
+ )
152
+ token = resp.token
153
+ for r in resp.actions:
154
+ yield cls(r)
155
+ if not token:
156
+ break
157
+
158
+ @syncify
159
+ @classmethod
160
+ async def get(cls, uri: str | None = None, /, run_name: str | None = None, name: str | None = None) -> Action:
161
+ """
162
+ Get a run by its ID or name. If both are provided, the ID will take precedence.
163
+
164
+ :param uri: The URI of the action.
165
+ :param run_name: The name of the action.
166
+ :param name: The name of the action.
167
+ """
168
+ ensure_client()
169
+ cfg = get_common_config()
170
+ details: ActionDetails = await ActionDetails.get_details.aio(
171
+ run_definition_pb2.ActionIdentifier(
172
+ run=run_definition_pb2.RunIdentifier(
173
+ org=cfg.org,
174
+ project=cfg.project,
175
+ domain=cfg.domain,
176
+ name=run_name,
177
+ ),
178
+ name=name,
179
+ ),
180
+ )
181
+ return cls(
182
+ pb2=run_definition_pb2.Action(
183
+ id=details.action_id,
184
+ metadata=details.pb2.metadata,
185
+ status=details.pb2.status,
186
+ ),
187
+ _details=details,
188
+ )
189
+
190
+ @property
191
+ def phase(self) -> str:
192
+ """
193
+ Get the phase of the action.
194
+ """
195
+ return run_definition_pb2.Phase.Name(self.pb2.status.phase)
196
+
197
+ @property
198
+ def raw_phase(self) -> run_definition_pb2.Phase:
199
+ """
200
+ Get the raw phase of the action.
201
+ """
202
+ return self.pb2.status.phase
203
+
204
+ @property
205
+ def name(self) -> str:
206
+ """
207
+ Get the name of the action.
208
+ """
209
+ return self.action_id.name
210
+
211
+ @property
212
+ def run_name(self) -> str:
213
+ """
214
+ Get the name of the run.
215
+ """
216
+ return self.action_id.run.name
217
+
218
+ @property
219
+ def task_name(self) -> str | None:
220
+ """
221
+ Get the name of the task.
222
+ """
223
+ if self.pb2.metadata.HasField("task") and self.pb2.metadata.task.HasField("id"):
224
+ return self.pb2.metadata.task.id.name
225
+ return None
226
+
227
+ @property
228
+ def action_id(self) -> run_definition_pb2.ActionIdentifier:
229
+ """
230
+ Get the action ID.
231
+ """
232
+ return self.pb2.id
233
+
234
+ async def show_logs(
235
+ self,
236
+ attempt: int | None = None,
237
+ max_lines: int = 30,
238
+ show_ts: bool = False,
239
+ raw: bool = False,
240
+ filter_system: bool = False,
241
+ ):
242
+ details = await self.details()
243
+ if not details.is_running and not details.done():
244
+ # TODO we can short circuit here if the attempt is not the last one and it is done!
245
+ await self.wait(wait_for="logs-ready")
246
+ details = await self.details()
247
+ if not attempt:
248
+ attempt = details.attempts
249
+ return await Logs.create_viewer(
250
+ action_id=self.action_id,
251
+ attempt=attempt,
252
+ max_lines=max_lines,
253
+ show_ts=show_ts,
254
+ raw=raw,
255
+ filter_system=filter_system,
256
+ )
257
+
258
+ async def details(self) -> ActionDetails:
259
+ """
260
+ Get the details of the action. This is a placeholder for getting the action details.
261
+ """
262
+ if not self._details:
263
+ self._details = await ActionDetails.get_details.aio(self.action_id)
264
+ return cast(ActionDetails, self._details)
265
+
266
+ async def watch(
267
+ self, cache_data_on_done: bool = False, wait_for: WaitFor = "terminal"
268
+ ) -> AsyncGenerator[ActionDetails, None]:
269
+ """
270
+ Watch the action for updates. This is a placeholder for watching the action.
271
+ """
272
+ ad = None
273
+ async for ad in ActionDetails.watch.aio(self.action_id):
274
+ if ad is None:
275
+ return
276
+ self._details = ad
277
+ yield ad
278
+ if wait_for == "running" and ad.is_running:
279
+ break
280
+ elif wait_for == "logs-ready" and ad.logs_available():
281
+ break
282
+ if ad.done():
283
+ break
284
+ if cache_data_on_done and ad and ad.done():
285
+ await cast(ActionDetails, self._details).outputs()
286
+
287
+ async def wait(self, quiet: bool = False, wait_for: WaitFor = "terminal") -> None:
288
+ """
289
+ Wait for the run to complete, displaying a rich progress panel with status transitions,
290
+ time elapsed, and error details in case of failure.
291
+ """
292
+ console = Console()
293
+ if self.done():
294
+ if not quiet:
295
+ if self.pb2.status.phase == run_definition_pb2.PHASE_SUCCEEDED:
296
+ console.print(
297
+ f"[bold green]Action '{self.name}' in Run '{self.run_name}'"
298
+ f" completed successfully.[/bold green]"
299
+ )
300
+ else:
301
+ details = await self.details()
302
+ console.print(
303
+ f"[bold red]Action '{self.name}' in Run '{self.run_name}'"
304
+ f" exited unsuccessfully in state {self.phase} with error: {details.error_info}[/bold red]"
305
+ )
306
+ return
307
+
308
+ try:
309
+ with Progress(
310
+ SpinnerColumn(),
311
+ TextColumn("[progress.description]{task.description}"),
312
+ TimeElapsedColumn(),
313
+ console=console,
314
+ transient=True,
315
+ disable=quiet,
316
+ ) as progress:
317
+ task_id = progress.add_task(f"Waiting for run '{self.name}'...", start=False)
318
+ progress.start_task(task_id)
319
+
320
+ async for ad in self.watch(cache_data_on_done=True, wait_for=wait_for):
321
+ if ad is None:
322
+ progress.stop_task(task_id)
323
+ break
324
+
325
+ if ad.is_running and wait_for == "running":
326
+ progress.start_task(task_id)
327
+ break
328
+
329
+ if ad.logs_available() and wait_for == "logs-ready":
330
+ progress.start_task(task_id)
331
+ break
332
+
333
+ # Update progress description with the current phase
334
+ progress.update(
335
+ task_id,
336
+ description=f"Run: {self.run_name} in {ad.phase}, Runtime: {ad.runtime} secs "
337
+ f"Attempts[{ad.attempts}]",
338
+ )
339
+
340
+ # If the action is done, handle the final state
341
+ if ad.done():
342
+ progress.stop_task(task_id)
343
+ if ad.pb2.status.phase == run_definition_pb2.PHASE_SUCCEEDED:
344
+ console.print(f"[bold green]Run '{self.run_name}' completed successfully.[/bold green]")
345
+ else:
346
+ console.print(
347
+ f"[bold red]Run '{self.run_name}' exited unsuccessfully in state {ad.phase}"
348
+ f" with error: {ad.error_info}[/bold red]"
349
+ )
350
+ break
351
+ except asyncio.CancelledError:
352
+ # Handle cancellation gracefully
353
+ pass
354
+ except KeyboardInterrupt:
355
+ # Handle keyboard interrupt gracefully
356
+ pass
357
+
358
+ def done(self) -> bool:
359
+ """
360
+ Check if the action is done.
361
+ """
362
+ return _action_done_check(self.raw_phase)
363
+
364
+ async def sync(self) -> Action:
365
+ """
366
+ Sync the action with the remote server. This is a placeholder for syncing the action.
367
+ """
368
+ return self
369
+
370
+ def __rich_repr__(self) -> rich.repr.Result:
371
+ """
372
+ Rich representation of the Action object.
373
+ """
374
+ yield from _action_rich_repr(self.pb2)
375
+ if self._details:
376
+ yield from self._details.__rich_repr__()
377
+
378
+ def __repr__(self) -> str:
379
+ """
380
+ String representation of the Action object.
381
+ """
382
+ import rich.pretty
383
+
384
+ return rich.pretty.pretty_repr(self)
385
+
386
+
387
+ @dataclass
388
+ class ActionDetails:
389
+ """
390
+ A class representing an action. It is used to manage the run of a task and its state on the remote Union API.
391
+ """
392
+
393
+ pb2: run_definition_pb2.ActionDetails
394
+ _inputs: ActionInputs | None = None
395
+ _outputs: ActionOutputs | None = None
396
+
397
+ @syncify
398
+ @classmethod
399
+ async def get_details(cls, action_id: run_definition_pb2.ActionIdentifier) -> ActionDetails:
400
+ """
401
+ Get the details of the action. This is a placeholder for getting the action details.
402
+ """
403
+ ensure_client()
404
+ resp = await get_client().run_service.GetActionDetails(
405
+ run_service_pb2.GetActionDetailsRequest(
406
+ action_id=action_id,
407
+ )
408
+ )
409
+ return ActionDetails(resp.details)
410
+
411
+ @syncify
412
+ @classmethod
413
+ async def get(
414
+ cls, uri: str | None = None, /, run_name: str | None = None, name: str | None = None
415
+ ) -> ActionDetails:
416
+ """
417
+ Get a run by its ID or name. If both are provided, the ID will take precedence.
418
+
419
+ :param uri: The URI of the action.
420
+ :param name: The name of the action.
421
+ :param run_name: The name of the run.
422
+ """
423
+ ensure_client()
424
+ if not uri:
425
+ assert name is not None and run_name is not None, "Either uri or name and run_name must be provided"
426
+ cfg = get_common_config()
427
+ return await cls.get_details.aio(
428
+ run_definition_pb2.ActionIdentifier(
429
+ run=run_definition_pb2.RunIdentifier(
430
+ org=cfg.org,
431
+ project=cfg.project,
432
+ domain=cfg.domain,
433
+ name=run_name,
434
+ ),
435
+ name=name,
436
+ ),
437
+ )
438
+
439
+ @syncify
440
+ @classmethod
441
+ async def watch(cls, action_id: run_definition_pb2.ActionIdentifier) -> AsyncIterator[ActionDetails]:
442
+ """
443
+ Watch the action for updates. This is a placeholder for watching the action.
444
+ """
445
+ ensure_client()
446
+ if not action_id:
447
+ raise ValueError("Action ID is required")
448
+
449
+ call = cast(
450
+ AsyncIterator[WatchActionDetailsResponse],
451
+ get_client().run_service.WatchActionDetails(
452
+ request=run_service_pb2.WatchActionDetailsRequest(
453
+ action_id=action_id,
454
+ )
455
+ ),
456
+ )
457
+ try:
458
+ async for resp in call:
459
+ v = cls(resp.details)
460
+ yield v
461
+ if v.done():
462
+ return
463
+ except grpc.aio.AioRpcError as e:
464
+ if e.code() == grpc.StatusCode.CANCELLED:
465
+ pass
466
+ else:
467
+ raise e
468
+
469
+ async def watch_updates(self, cache_data_on_done: bool = False) -> AsyncGenerator[ActionDetails, None]:
470
+ async for d in self.watch.aio(action_id=self.pb2.id):
471
+ yield d
472
+ if d.done():
473
+ self.pb2 = d.pb2
474
+ break
475
+
476
+ if cache_data_on_done and self.done():
477
+ await self._cache_data.aio()
478
+
479
+ @property
480
+ def phase(self) -> str:
481
+ """
482
+ Get the phase of the action.
483
+ """
484
+ return run_definition_pb2.Phase.Name(self.status.phase)
485
+
486
+ @property
487
+ def raw_phase(self) -> run_definition_pb2.Phase:
488
+ """
489
+ Get the raw phase of the action.
490
+ """
491
+ return self.status.phase
492
+
493
+ @property
494
+ def is_running(self) -> bool:
495
+ """
496
+ Check if the action is currently running.
497
+ """
498
+ return self.status.phase == run_definition_pb2.PHASE_RUNNING
499
+
500
+ @property
501
+ def name(self) -> str:
502
+ """
503
+ Get the name of the action.
504
+ """
505
+ return self.action_id.name
506
+
507
+ @property
508
+ def run_name(self) -> str:
509
+ """
510
+ Get the name of the run.
511
+ """
512
+ return self.action_id.run.name
513
+
514
+ @property
515
+ def task_name(self) -> str | None:
516
+ """
517
+ Get the name of the task.
518
+ """
519
+ if self.pb2.metadata.HasField("task") and self.pb2.metadata.task.HasField("id"):
520
+ return self.pb2.metadata.task.id.name
521
+ return None
522
+
523
+ @property
524
+ def action_id(self) -> run_definition_pb2.ActionIdentifier:
525
+ """
526
+ Get the action ID.
527
+ """
528
+ return self.pb2.id
529
+
530
+ @property
531
+ def metadata(self) -> run_definition_pb2.ActionMetadata:
532
+ return self.pb2.metadata
533
+
534
+ @property
535
+ def status(self) -> run_definition_pb2.ActionStatus:
536
+ return self.pb2.status
537
+
538
+ @property
539
+ def error_info(self) -> run_definition_pb2.ErrorInfo | None:
540
+ if self.pb2.HasField("error_info"):
541
+ return self.pb2.error_info
542
+ return None
543
+
544
+ @property
545
+ def abort_info(self) -> run_definition_pb2.AbortInfo | None:
546
+ if self.pb2.HasField("abort_info"):
547
+ return self.pb2.abort_info
548
+ return None
549
+
550
+ @property
551
+ def runtime(self) -> timedelta:
552
+ """
553
+ Get the runtime of the action.
554
+ """
555
+ start_time = timestamp.to_datetime(self.pb2.status.start_time, timezone.utc)
556
+ if self.pb2.status.HasField("end_time"):
557
+ end_time = timestamp.to_datetime(self.pb2.status.end_time, timezone.utc)
558
+ return end_time - start_time
559
+ return datetime.now(timezone.utc) - start_time
560
+
561
+ @property
562
+ def attempts(self) -> int:
563
+ """
564
+ Get the number of attempts of the action.
565
+ """
566
+ return self.pb2.status.attempts
567
+
568
+ def logs_available(self, attempt: int | None = None) -> bool:
569
+ """
570
+ Check if logs are available for the action, optionally for a specific attempt.
571
+ If attempt is None, it checks for the latest attempt.
572
+ """
573
+ if attempt is None:
574
+ attempt = self.pb2.status.attempts
575
+ attempts = self.pb2.attempts
576
+ if attempts and len(attempts) >= attempt:
577
+ return attempts[attempt - 1].logs_available
578
+ return False
579
+
580
+ @syncify
581
+ async def _cache_data(self) -> bool:
582
+ """
583
+ Cache the inputs and outputs of the action.
584
+ :return: Returns True if Action is terminal and all data is cached else False.
585
+ """
586
+ from flyte._internal.runtime import convert
587
+
588
+ if self._inputs and self._outputs:
589
+ return True
590
+ if self._inputs and not self.done():
591
+ return False
592
+ resp = await get_client().run_service.GetActionData(
593
+ request=run_service_pb2.GetActionDataRequest(
594
+ action_id=self.pb2.id,
595
+ )
596
+ )
597
+ native_iface = None
598
+ if self.pb2.resolved_task_spec:
599
+ iface = self.pb2.resolved_task_spec.task_template.interface
600
+ native_iface = types.guess_interface(iface)
601
+
602
+ if resp.inputs:
603
+ data_dict = (
604
+ await convert.convert_from_inputs_to_native(native_iface, convert.Inputs(resp.inputs))
605
+ if native_iface
606
+ else {}
607
+ )
608
+ self._inputs = ActionInputs(pb2=resp.inputs, data=data_dict)
609
+
610
+ if resp.outputs:
611
+ data_tuple = (
612
+ await convert.convert_outputs_to_native(native_iface, convert.Outputs(resp.outputs))
613
+ if native_iface
614
+ else ()
615
+ )
616
+ if not isinstance(data_tuple, tuple):
617
+ data_tuple = (data_tuple,)
618
+ self._outputs = ActionOutputs(pb2=resp.outputs, data=data_tuple)
619
+
620
+ return self._outputs is not None
621
+
622
+ async def inputs(self) -> ActionInputs:
623
+ """
624
+ Placeholder for inputs. This can be extended to handle inputs from the run context.
625
+ """
626
+ if not self._inputs:
627
+ await self._cache_data.aio()
628
+ return cast(ActionInputs, self._inputs)
629
+
630
+ async def outputs(self) -> ActionOutputs:
631
+ """
632
+ Placeholder for outputs. This can be extended to handle outputs from the run context.
633
+ """
634
+ if not self._outputs:
635
+ if not await self._cache_data.aio():
636
+ raise RuntimeError(
637
+ "Action is not in a terminal state, outputs are not available. "
638
+ "Please wait for the action to complete."
639
+ )
640
+ return cast(ActionOutputs, self._outputs)
641
+
642
+ def done(self) -> bool:
643
+ """
644
+ Check if the action is in a terminal state (completed or failed). This is a placeholder for checking the
645
+ action state.
646
+ """
647
+ return _action_done_check(self.raw_phase)
648
+
649
+ def __rich_repr__(self) -> rich.repr.Result:
650
+ """
651
+ Rich representation of the Action object.
652
+ """
653
+ yield from _action_details_rich_repr(self.pb2)
654
+
655
+ def __repr__(self) -> str:
656
+ """
657
+ String representation of the Action object.
658
+ """
659
+ import rich.pretty
660
+
661
+ return rich.pretty.pretty_repr(self)
662
+
663
+
664
+ @dataclass
665
+ class ActionInputs(UserDict):
666
+ """
667
+ A class representing the inputs of an action. It is used to manage the inputs of a task and its state on the
668
+ remote Union API.
669
+ """
670
+
671
+ pb2: run_definition_pb2.Inputs
672
+ data: Dict[str, Any]
673
+
674
+ def __repr__(self):
675
+ import rich.pretty
676
+
677
+ import flyte.types as types
678
+
679
+ return rich.pretty.pretty_repr(types.literal_string_repr(self.pb2))
680
+
681
+
682
+ class ActionOutputs(tuple):
683
+ """
684
+ A class representing the outputs of an action. It is used to manage the outputs of a task and its state on the
685
+ remote Union API.
686
+ """
687
+
688
+ def __new__(cls, pb2: run_definition_pb2.Outputs, data: Tuple[Any, ...]):
689
+ # Create the tuple part
690
+ obj = super().__new__(cls, data)
691
+ # Store extra data (you can't do this here directly since it's immutable)
692
+ obj.pb2 = pb2
693
+ return obj
694
+
695
+ def __init__(self, pb2: run_definition_pb2.Outputs, data: Tuple[Any, ...]):
696
+ # Normally you'd set instance attributes here,
697
+ # but we've already set `pb2` in `__new__`
698
+ self.pb2 = pb2