opentrons 8.7.0a6__py3-none-any.whl → 8.7.0a7__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 opentrons might be problematic. Click here for more details.

Files changed (144) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/drivers/asyncio/communication/serial_connection.py +129 -52
  3. opentrons/drivers/heater_shaker/abstract.py +5 -0
  4. opentrons/drivers/heater_shaker/driver.py +10 -0
  5. opentrons/drivers/heater_shaker/simulator.py +4 -0
  6. opentrons/drivers/thermocycler/abstract.py +6 -0
  7. opentrons/drivers/thermocycler/driver.py +61 -10
  8. opentrons/drivers/thermocycler/simulator.py +6 -0
  9. opentrons/hardware_control/api.py +24 -5
  10. opentrons/hardware_control/backends/controller.py +8 -2
  11. opentrons/hardware_control/backends/ot3controller.py +3 -0
  12. opentrons/hardware_control/backends/ot3simulator.py +2 -1
  13. opentrons/hardware_control/backends/simulator.py +2 -1
  14. opentrons/hardware_control/backends/subsystem_manager.py +5 -2
  15. opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
  16. opentrons/hardware_control/emulation/connection_handler.py +8 -5
  17. opentrons/hardware_control/emulation/heater_shaker.py +12 -3
  18. opentrons/hardware_control/emulation/settings.py +1 -1
  19. opentrons/hardware_control/emulation/thermocycler.py +67 -15
  20. opentrons/hardware_control/module_control.py +82 -8
  21. opentrons/hardware_control/modules/__init__.py +3 -0
  22. opentrons/hardware_control/modules/absorbance_reader.py +11 -4
  23. opentrons/hardware_control/modules/flex_stacker.py +38 -9
  24. opentrons/hardware_control/modules/heater_shaker.py +42 -5
  25. opentrons/hardware_control/modules/magdeck.py +8 -4
  26. opentrons/hardware_control/modules/mod_abc.py +13 -5
  27. opentrons/hardware_control/modules/tempdeck.py +25 -5
  28. opentrons/hardware_control/modules/thermocycler.py +68 -11
  29. opentrons/hardware_control/modules/types.py +20 -1
  30. opentrons/hardware_control/modules/utils.py +11 -4
  31. opentrons/hardware_control/nozzle_manager.py +3 -0
  32. opentrons/hardware_control/ot3api.py +26 -5
  33. opentrons/hardware_control/poller.py +22 -8
  34. opentrons/hardware_control/scripts/update_module_fw.py +5 -0
  35. opentrons/hardware_control/types.py +31 -2
  36. opentrons/legacy_commands/module_commands.py +23 -0
  37. opentrons/legacy_commands/protocol_commands.py +20 -0
  38. opentrons/legacy_commands/types.py +80 -0
  39. opentrons/motion_planning/deck_conflict.py +17 -12
  40. opentrons/motion_planning/waypoints.py +15 -29
  41. opentrons/protocol_api/__init__.py +5 -1
  42. opentrons/protocol_api/_types.py +6 -1
  43. opentrons/protocol_api/core/common.py +3 -1
  44. opentrons/protocol_api/core/engine/_default_labware_versions.py +32 -11
  45. opentrons/protocol_api/core/engine/labware.py +8 -1
  46. opentrons/protocol_api/core/engine/module_core.py +75 -8
  47. opentrons/protocol_api/core/engine/protocol.py +18 -1
  48. opentrons/protocol_api/core/engine/tasks.py +48 -0
  49. opentrons/protocol_api/core/engine/well.py +8 -0
  50. opentrons/protocol_api/core/legacy/legacy_module_core.py +24 -4
  51. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +11 -1
  52. opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
  53. opentrons/protocol_api/core/legacy/tasks.py +19 -0
  54. opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
  55. opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
  56. opentrons/protocol_api/core/module.py +37 -4
  57. opentrons/protocol_api/core/protocol.py +11 -2
  58. opentrons/protocol_api/core/tasks.py +31 -0
  59. opentrons/protocol_api/core/well.py +4 -0
  60. opentrons/protocol_api/labware.py +5 -0
  61. opentrons/protocol_api/module_contexts.py +117 -11
  62. opentrons/protocol_api/protocol_context.py +26 -4
  63. opentrons/protocol_api/robot_context.py +38 -21
  64. opentrons/protocol_api/tasks.py +48 -0
  65. opentrons/protocol_api/validation.py +6 -1
  66. opentrons/protocol_engine/actions/__init__.py +4 -2
  67. opentrons/protocol_engine/actions/actions.py +22 -9
  68. opentrons/protocol_engine/clients/sync_client.py +42 -7
  69. opentrons/protocol_engine/commands/__init__.py +42 -0
  70. opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
  71. opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
  72. opentrons/protocol_engine/commands/aspirate.py +1 -0
  73. opentrons/protocol_engine/commands/command.py +1 -0
  74. opentrons/protocol_engine/commands/command_unions.py +49 -0
  75. opentrons/protocol_engine/commands/create_timer.py +83 -0
  76. opentrons/protocol_engine/commands/dispense.py +1 -0
  77. opentrons/protocol_engine/commands/drop_tip.py +32 -8
  78. opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
  79. opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
  80. opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
  81. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
  82. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
  83. opentrons/protocol_engine/commands/movement_common.py +2 -0
  84. opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
  85. opentrons/protocol_engine/commands/set_tip_state.py +97 -0
  86. opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
  87. opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
  88. opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
  89. opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
  90. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +40 -6
  91. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +29 -5
  92. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
  93. opentrons/protocol_engine/commands/touch_tip.py +1 -1
  94. opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
  95. opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
  96. opentrons/protocol_engine/errors/__init__.py +4 -0
  97. opentrons/protocol_engine/errors/exceptions.py +55 -0
  98. opentrons/protocol_engine/execution/__init__.py +2 -0
  99. opentrons/protocol_engine/execution/command_executor.py +8 -0
  100. opentrons/protocol_engine/execution/create_queue_worker.py +5 -1
  101. opentrons/protocol_engine/execution/labware_movement.py +9 -12
  102. opentrons/protocol_engine/execution/movement.py +2 -0
  103. opentrons/protocol_engine/execution/queue_worker.py +4 -0
  104. opentrons/protocol_engine/execution/run_control.py +8 -0
  105. opentrons/protocol_engine/execution/task_handler.py +157 -0
  106. opentrons/protocol_engine/protocol_engine.py +75 -34
  107. opentrons/protocol_engine/resources/__init__.py +2 -0
  108. opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
  109. opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
  110. opentrons/protocol_engine/resources/labware_validation.py +10 -6
  111. opentrons/protocol_engine/state/_well_math.py +60 -18
  112. opentrons/protocol_engine/state/addressable_areas.py +2 -0
  113. opentrons/protocol_engine/state/commands.py +14 -11
  114. opentrons/protocol_engine/state/geometry.py +213 -374
  115. opentrons/protocol_engine/state/labware.py +52 -102
  116. opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
  117. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1331 -0
  118. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
  119. opentrons/protocol_engine/state/modules.py +21 -8
  120. opentrons/protocol_engine/state/motion.py +44 -0
  121. opentrons/protocol_engine/state/state.py +14 -0
  122. opentrons/protocol_engine/state/state_summary.py +2 -0
  123. opentrons/protocol_engine/state/tasks.py +139 -0
  124. opentrons/protocol_engine/state/tips.py +177 -258
  125. opentrons/protocol_engine/state/update_types.py +16 -9
  126. opentrons/protocol_engine/types/__init__.py +9 -3
  127. opentrons/protocol_engine/types/deck_configuration.py +5 -1
  128. opentrons/protocol_engine/types/instrument.py +8 -1
  129. opentrons/protocol_engine/types/labware.py +1 -13
  130. opentrons/protocol_engine/types/module.py +10 -0
  131. opentrons/protocol_engine/types/tasks.py +38 -0
  132. opentrons/protocol_engine/types/tip.py +9 -0
  133. opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
  134. opentrons/protocol_runner/run_orchestrator.py +18 -2
  135. opentrons/protocols/api_support/definitions.py +1 -1
  136. opentrons/protocols/api_support/types.py +2 -1
  137. opentrons/simulate.py +48 -15
  138. opentrons/system/camera.py +1 -1
  139. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/METADATA +4 -4
  140. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/RECORD +143 -127
  141. opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
  142. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/WHEEL +0 -0
  143. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/entry_points.txt +0 -0
  144. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ from typing import NewType, Optional
5
5
  from opentrons.protocol_engine.errors import (
6
6
  InvalidTargetTemperatureError,
7
7
  InvalidBlockVolumeError,
8
+ InvalidRampRateError,
8
9
  NoTargetTemperatureSetError,
9
10
  InvalidHoldTimeError,
10
11
  )
@@ -23,6 +24,10 @@ from opentrons.hardware_control.modules import ModuleData, ModuleDataValidator
23
24
 
24
25
  ThermocyclerModuleId = NewType("ThermocyclerModuleId", str)
25
26
 
27
+ # These are our published numbers, and from testing they are good bounds
28
+ MAX_HEATING_RATE = 4.25
29
+ MAX_COOLING_RATE = 2.0
30
+
26
31
 
27
32
  @dataclass(frozen=True)
28
33
  class ThermocyclerModuleSubState:
@@ -143,6 +148,38 @@ class ThermocyclerModuleSubState:
143
148
  )
144
149
  return target
145
150
 
151
+ def validate_ramp_rate(
152
+ self, ramp_rate: Optional[float], target_temp: float
153
+ ) -> Optional[float]:
154
+ """Validate a given temperature ramp rate.
155
+
156
+ Args:
157
+ ramp_rate: The requested ramp rate in °C/second.
158
+ target_temp: The requested block temperature.
159
+
160
+ Raises:
161
+ InvalidRampRateError: The given ramp_rate is invalid
162
+
163
+ Returns:
164
+ The validated ramp rate in °C/second
165
+ """
166
+ if ramp_rate is None:
167
+ return ramp_rate
168
+
169
+ heating = target_temp > self.get_target_block_temperature()
170
+ if (heating and ramp_rate > MAX_HEATING_RATE) or (
171
+ not heating and ramp_rate > MAX_COOLING_RATE
172
+ ):
173
+ raise InvalidRampRateError(
174
+ f"Thermocycler ramp rate cannot exceed {MAX_HEATING_RATE}°C/s"
175
+ f" while heating or {MAX_COOLING_RATE}°C/s when cooling."
176
+ )
177
+ if ramp_rate <= 0:
178
+ raise InvalidRampRateError(
179
+ f"Thermocycler ramp rate cannot be less than or equal to 0, got {ramp_rate}"
180
+ )
181
+ return ramp_rate
182
+
146
183
  @classmethod
147
184
  def from_live_data(
148
185
  cls, module_id: ThermocyclerModuleId, data: ModuleData | None
@@ -56,7 +56,6 @@ from ..types import (
56
56
  HeaterShakerLatchStatus,
57
57
  HeaterShakerMovementRestrictors,
58
58
  DeckType,
59
- LabwareMovementOffsetData,
60
59
  AddressableAreaLocation,
61
60
  StackerStoredLabwareGroup,
62
61
  )
@@ -1336,13 +1335,6 @@ class ModuleView:
1336
1335
  return True
1337
1336
  return False
1338
1337
 
1339
- def get_default_gripper_offsets(
1340
- self, module_id: str
1341
- ) -> Optional[LabwareMovementOffsetData]:
1342
- """Get the deck's default gripper offsets."""
1343
- offsets = self.get_definition(module_id).gripperOffsets
1344
- return offsets.get("default") if offsets else None
1345
-
1346
1338
  def get_overflowed_module_in_slot(
1347
1339
  self, slot_name: DeckSlotName
1348
1340
  ) -> Optional[LoadedModule]:
@@ -1519,3 +1511,24 @@ class ModuleView:
1519
1511
  f"Provided overlap offset {overlap_offset} does not match "
1520
1512
  f"configured {configured}."
1521
1513
  )
1514
+
1515
+ def get_has_module_probably_matching_hardware_details(
1516
+ self, module_model: ModuleModel, module_serial: str | None
1517
+ ) -> bool:
1518
+ """Get the ID of a model that possibly matches the provided details.
1519
+
1520
+ If the provided serial is not None, return True if there is a module with the same serial or
1521
+ False if there is not.
1522
+ If the provided serial is None, return True if there is a module with the same model or False if
1523
+ there is not.
1524
+
1525
+ This is intended to provide a good probability that a module matching the provided details
1526
+ is or is not present in the state store. It is used to drive whether the engine cancels a protocol
1527
+ in response to an asynchronous module error or not.
1528
+ """
1529
+ for module_id, module in self._state.hardware_by_module_id.items():
1530
+ if module_serial is not None and module_serial == module.serial_number:
1531
+ return True
1532
+ if module_serial is None and module.definition.model == module_model:
1533
+ return True
1534
+ return False
@@ -1,6 +1,7 @@
1
1
  """Motion state store and getters."""
2
2
  from dataclasses import dataclass
3
3
  from typing import List, Optional, Union
4
+ import logging
4
5
 
5
6
  from opentrons.types import MountType, Point, StagingSlotName
6
7
  from opentrons.hardware_control.types import CriticalPoint
@@ -28,6 +29,8 @@ from .geometry import GeometryView
28
29
  from .modules import ModuleView
29
30
  from .module_substates import HeaterShakerModuleId
30
31
 
32
+ log = logging.getLogger(__name__)
33
+
31
34
 
32
35
  @dataclass(frozen=True)
33
36
  class PipetteLocationData:
@@ -85,6 +88,42 @@ class MotionView:
85
88
  critical_point = CriticalPoint.XY_CENTER
86
89
  return PipetteLocationData(mount=mount, critical_point=critical_point)
87
90
 
91
+ def _get_pipette_offset_for_reservoirs(
92
+ self, labware_id: str, well_name: str, pipette_id: str
93
+ ) -> Point:
94
+ # 8 rows, 12 columns
95
+ subwells_96 = self._labware.get_has_96_subwells(labware_id)
96
+ # 1 row, 12 columns
97
+ subwells_12 = self._labware.get_has_12_subwells(labware_id)
98
+ if subwells_12 and subwells_96:
99
+ log.warning(
100
+ f"{self._labware.get_display_name(labware_id)} has both offsetPipetteFor96GridSubwells and"
101
+ " offsetPipetteFor12GridSubwells quirks."
102
+ )
103
+
104
+ pipette_rows = self._pipettes.get_nozzle_configuration(pipette_id).rows
105
+ pipette_cols = self._pipettes.get_nozzle_configuration(pipette_id).columns
106
+
107
+ even_labware_rows = subwells_96
108
+ even_labware_columns = subwells_96 or subwells_12
109
+ odd_pipette_rows = len(pipette_rows) % 2 == 1
110
+ odd_pipette_cols = len(pipette_cols) % 2 == 1
111
+
112
+ well_x_dim, well_y_dim, well_z_dim = self._labware.get_well_size(
113
+ labware_id=labware_id, well_name=well_name
114
+ )
115
+ x_offset = 0.0
116
+ y_offset = 0.0
117
+ if even_labware_rows and odd_pipette_rows:
118
+ # need to move up half a row
119
+ # there's 8 rows, so move 1/16 of reservoir length
120
+ y_offset = well_y_dim / 16
121
+ if even_labware_columns and odd_pipette_cols:
122
+ # need to move left half a column
123
+ # there's 12 columns, so move 1/24 of reservoir width
124
+ x_offset = -1 * well_x_dim / 24
125
+ return Point(x=x_offset, y=y_offset)
126
+
88
127
  def get_movement_waypoints_to_well(
89
128
  self,
90
129
  pipette_id: str,
@@ -98,6 +137,7 @@ class MotionView:
98
137
  force_direct: bool = False,
99
138
  minimum_z_height: Optional[float] = None,
100
139
  operation_volume: Optional[float] = None,
140
+ offset_pipette_for_reservoir_subwells: bool = False,
101
141
  ) -> List[motion_planning.Waypoint]:
102
142
  """Calculate waypoints to a destination that's specified as a well."""
103
143
  location = current_well or self._pipettes.get_current_location()
@@ -115,6 +155,10 @@ class MotionView:
115
155
  operation_volume=operation_volume,
116
156
  pipette_id=pipette_id,
117
157
  )
158
+ if offset_pipette_for_reservoir_subwells:
159
+ destination += self._get_pipette_offset_for_reservoirs(
160
+ labware_id=labware_id, well_name=well_name, pipette_id=pipette_id
161
+ )
118
162
 
119
163
  move_type = _move_types.get_move_type_to_well(
120
164
  pipette_id, labware_id, well_name, location, force_direct
@@ -34,6 +34,7 @@ from .files import FileView, FileState, FileStore
34
34
  from .config import Config
35
35
  from .state_summary import StateSummary
36
36
  from ..types import DeckConfigurationType
37
+ from .tasks import TaskState, TaskView, TaskStore
37
38
 
38
39
 
39
40
  _ParamsT = ParamSpec("_ParamsT")
@@ -54,6 +55,7 @@ class State:
54
55
  tips: TipState
55
56
  wells: WellState
56
57
  files: FileState
58
+ tasks: TaskState
57
59
 
58
60
 
59
61
  class StateView(HasState[State]):
@@ -73,6 +75,7 @@ class StateView(HasState[State]):
73
75
  _motion: MotionView
74
76
  _files: FileView
75
77
  _config: Config
78
+ _tasks: TaskView
76
79
 
77
80
  @property
78
81
  def commands(self) -> CommandView:
@@ -139,6 +142,11 @@ class StateView(HasState[State]):
139
142
  """Get ProtocolEngine configuration."""
140
143
  return self._config
141
144
 
145
+ @property
146
+ def tasks(self) -> TaskView:
147
+ """Get state view selectors for task state."""
148
+ return self._tasks
149
+
142
150
  def get_summary(self) -> StateSummary:
143
151
  """Get protocol run data."""
144
152
  error = self._commands.get_error()
@@ -162,6 +170,7 @@ class StateView(HasState[State]):
162
170
  )
163
171
  for liquid_class_id, liquid_class_record in self._liquid_classes.get_all().items()
164
172
  ],
173
+ tasks=self._tasks.get_summary(),
165
174
  )
166
175
 
167
176
 
@@ -231,6 +240,7 @@ class StateStore(StateView, ActionHandler):
231
240
  self._tip_store = TipStore()
232
241
  self._well_store = WellStore()
233
242
  self._file_store = FileStore()
243
+ self._task_store = TaskStore()
234
244
 
235
245
  self._substores: List[HandlesActions] = [
236
246
  self._command_store,
@@ -243,6 +253,7 @@ class StateStore(StateView, ActionHandler):
243
253
  self._tip_store,
244
254
  self._well_store,
245
255
  self._file_store,
256
+ self._task_store,
246
257
  ]
247
258
  self._config = config
248
259
  self._change_notifier = change_notifier or ChangeNotifier()
@@ -366,6 +377,7 @@ class StateStore(StateView, ActionHandler):
366
377
  tips=self._tip_store.state,
367
378
  wells=self._well_store.state,
368
379
  files=self._file_store.state,
380
+ tasks=self._task_store.state,
369
381
  )
370
382
 
371
383
  def _initialize_state(self) -> None:
@@ -384,6 +396,7 @@ class StateStore(StateView, ActionHandler):
384
396
  self._tips = TipView(state.tips)
385
397
  self._wells = WellView(state.wells)
386
398
  self._files = FileView(state.files)
399
+ self._tasks = TaskView(state.tasks)
387
400
 
388
401
  # Derived states
389
402
  self._geometry = GeometryView(
@@ -416,6 +429,7 @@ class StateStore(StateView, ActionHandler):
416
429
  self._liquid_classes._state = next_state.liquid_classes
417
430
  self._tips._state = next_state.tips
418
431
  self._wells._state = next_state.wells
432
+ self._tasks._state = next_state.tasks
419
433
  self._change_notifier.notify()
420
434
  if self._notify_robot_server is not None:
421
435
  self._notify_robot_server()
@@ -13,6 +13,7 @@ from ..types import (
13
13
  Liquid,
14
14
  LiquidClassRecordWithId,
15
15
  WellInfoSummary,
16
+ TaskSummary,
16
17
  )
17
18
 
18
19
 
@@ -34,3 +35,4 @@ class StateSummary(BaseModel):
34
35
  wells: List[WellInfoSummary] = Field(default_factory=list)
35
36
  files: List[str] = Field(default_factory=list)
36
37
  liquidClasses: List[LiquidClassRecordWithId] = Field(default_factory=list)
38
+ tasks: List[TaskSummary] = Field(default_factory=list)
@@ -0,0 +1,139 @@
1
+ """Task state tracking."""
2
+ from dataclasses import dataclass
3
+ from itertools import chain
4
+ from typing import Iterable
5
+ from ..types import Task, TaskSummary, FinishedTask
6
+ from ._abstract_store import HasState, HandlesActions
7
+ from opentrons.protocol_engine.state import update_types
8
+ from opentrons.protocol_engine.errors.exceptions import NoTaskFoundError
9
+ from ..actions import (
10
+ get_state_updates,
11
+ Action,
12
+ StartTaskAction,
13
+ FinishTaskAction,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class TaskState:
19
+ """Task state tracking."""
20
+
21
+ current_tasks_by_id: dict[str, Task]
22
+ finished_tasks_by_id: dict[str, FinishedTask]
23
+
24
+
25
+ class TaskStore(HasState[TaskState], HandlesActions):
26
+ """Stores tasks."""
27
+
28
+ _state: TaskState
29
+
30
+ def __init__(self) -> None:
31
+ """Initialize a TaskStore."""
32
+ self._state = TaskState(current_tasks_by_id={}, finished_tasks_by_id={})
33
+
34
+ def _handle_state_update(self, state_update: update_types.StateUpdate) -> None:
35
+ """Handle a state update."""
36
+ return
37
+
38
+ def _handle_start_task_action(self, action: StartTaskAction) -> None:
39
+ self._state.current_tasks_by_id[action.task.id] = action.task
40
+
41
+ def _handle_finish_task_action(self, action: FinishTaskAction) -> None:
42
+ task = self._state.current_tasks_by_id[action.task_id]
43
+ self._state.finished_tasks_by_id[action.task_id] = FinishedTask(
44
+ id=task.id,
45
+ createdAt=task.createdAt,
46
+ finishedAt=action.finished_at,
47
+ error=action.error,
48
+ )
49
+ del self._state.current_tasks_by_id[action.task_id]
50
+
51
+ def handle_action(self, action: Action) -> None:
52
+ """Modify the state in reaction to an action."""
53
+ for state_update in get_state_updates(action):
54
+ self._handle_state_update(state_update)
55
+ match action:
56
+ case StartTaskAction():
57
+ self._handle_start_task_action(action)
58
+ case FinishTaskAction():
59
+ self._handle_finish_task_action(action)
60
+ case _:
61
+ pass
62
+
63
+
64
+ class TaskView:
65
+ """Read-only task state view."""
66
+
67
+ _state: TaskState
68
+
69
+ def __init__(self, state: TaskState) -> None:
70
+ """Initialize a TaskView."""
71
+ self._state = state
72
+
73
+ def get_current(self, id: str) -> Task:
74
+ """Get a task by ID."""
75
+ try:
76
+ return self._state.current_tasks_by_id[id]
77
+ except KeyError as e:
78
+ raise NoTaskFoundError(f"No current task with ID {id}") from e
79
+
80
+ def get_all_current(self) -> list[Task]:
81
+ """Get all currently running tasks."""
82
+ return [task for task in self._state.current_tasks_by_id.values()]
83
+
84
+ def get_finished(self, id: str) -> FinishedTask:
85
+ """Get a finished task by ID."""
86
+ try:
87
+ return self._state.finished_tasks_by_id[id]
88
+ except KeyError as e:
89
+ raise NoTaskFoundError(f"No finished task with ID {id}") from e
90
+
91
+ def get(self, id: str) -> Task | FinishedTask:
92
+ """Get a single task by id."""
93
+ if id in self._state.current_tasks_by_id:
94
+ return self._state.current_tasks_by_id[id]
95
+ elif id in self._state.finished_tasks_by_id:
96
+ return self._state.finished_tasks_by_id[id]
97
+ else:
98
+ raise NoTaskFoundError(message=f"Task {id} not found.")
99
+
100
+ def get_summary(self) -> list[TaskSummary]:
101
+ """Get a summary of all tasks."""
102
+ return [
103
+ TaskSummary(
104
+ id=task_id,
105
+ createdAt=task.createdAt,
106
+ finishedAt=getattr(task, "finishedAt", None),
107
+ error=getattr(task, "error", None),
108
+ )
109
+ for task_id, task in chain(
110
+ self._state.current_tasks_by_id.items(),
111
+ self._state.finished_tasks_by_id.items(),
112
+ )
113
+ ]
114
+
115
+ def all_tasks_finished_or_any_task_failed(self, task_ids: Iterable[str]) -> bool:
116
+ """Implements wait semantics of asyncio.gather(return_exceptions = False).
117
+
118
+ This returns true when any of the following are true:
119
+ - All tasks in task_ids are complete with or without an error
120
+ - Any task in task_ids is complete with an error.
121
+
122
+ NOTE: Does not raise the error that the errored task has.
123
+ """
124
+ finished = set(self._state.finished_tasks_by_id.keys())
125
+ task_ids = set(task_ids)
126
+ if task_ids.issubset(finished):
127
+ return True
128
+ if self.get_failed_tasks(task_ids):
129
+ return True
130
+ return False
131
+
132
+ def get_failed_tasks(self, task_ids: Iterable[str]) -> list[str]:
133
+ """Return a list of failed task ids of the ones that were passed."""
134
+ failed_tasks: list[str] = []
135
+ for task_id in task_ids:
136
+ task = self._state.finished_tasks_by_id.get(task_id, None)
137
+ if task and task.error:
138
+ failed_tasks.append(task_id)
139
+ return failed_tasks