hyperloop 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,306 @@
1
+ """Pipeline executor — walks a task through pipeline steps.
2
+
3
+ Given the current pipeline position and a worker result, returns the next action
4
+ to take and the new position. No I/O, no side effects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ from hyperloop.domain.model import (
16
+ ActionStep,
17
+ GateStep,
18
+ LoopStep,
19
+ PipelinePosition,
20
+ PipelineStep,
21
+ RoleStep,
22
+ Verdict,
23
+ WorkerResult,
24
+ )
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Pipeline actions (output of next_action)
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SpawnRole:
33
+ """Spawn a worker with the given role."""
34
+
35
+ role: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class WaitForGate:
40
+ """Block until the named gate's external signal is received."""
41
+
42
+ gate: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PerformAction:
47
+ """Execute a terminal action (merge-pr, mark-pr-ready, etc.)."""
48
+
49
+ action: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PipelineComplete:
54
+ """The pipeline has been fully traversed — no more steps."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PipelineFailed:
59
+ """The pipeline cannot continue (fail with no enclosing loop)."""
60
+
61
+ reason: str
62
+
63
+
64
+ PipelineAction = SpawnRole | WaitForGate | PerformAction | PipelineComplete | PipelineFailed
65
+ """Union of all pipeline action types."""
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Pipeline executor class
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ class PipelineExecutor:
74
+ """Walks a task through a pipeline, returning the next action at each step."""
75
+
76
+ def __init__(self, pipeline: tuple[PipelineStep, ...]) -> None:
77
+ self._pipeline = pipeline
78
+
79
+ @property
80
+ def pipeline(self) -> tuple[PipelineStep, ...]:
81
+ """The pipeline steps this executor walks."""
82
+ return self._pipeline
83
+
84
+ def next_action(
85
+ self, position: PipelinePosition, result: WorkerResult | None
86
+ ) -> tuple[PipelineAction, PipelinePosition]:
87
+ """Determine the next pipeline action given the current position and result.
88
+
89
+ Pure method. No I/O. No side effects.
90
+
91
+ Args:
92
+ position: Current position in the pipeline (path of indices).
93
+ result: The result from the current step's worker, or None if just arriving.
94
+
95
+ Returns:
96
+ A tuple of (action to take, new position).
97
+ """
98
+ step = self.resolve_step(self._pipeline, position.path)
99
+
100
+ # --- No result: we're arriving at this step for the first time ---
101
+ if result is None:
102
+ return self._action_for_step(step), position
103
+
104
+ # --- Result received: decide what to do next ---
105
+ is_pass = result.verdict == Verdict.PASS
106
+
107
+ # Handle on_pass/on_fail overrides on RoleStep
108
+ if isinstance(step, RoleStep):
109
+ override = step.on_pass if is_pass else step.on_fail
110
+ if override is not None:
111
+ # Find the named step in the current step list (same nesting level)
112
+ parent_steps: Sequence[PipelineStep] = self._pipeline
113
+ prefix: tuple[int, ...] = ()
114
+ if len(position.path) > 1:
115
+ # Walk to the parent's step list
116
+ for idx in position.path[:-1]:
117
+ s = parent_steps[idx]
118
+ if isinstance(s, LoopStep):
119
+ parent_steps = s.steps
120
+ prefix = (*prefix, idx)
121
+ else:
122
+ break
123
+
124
+ target_idx = self._find_step_by_role(parent_steps, override)
125
+ if target_idx is not None:
126
+ return self._descend_to_leaf(parent_steps, prefix, target_idx)
127
+
128
+ if is_pass:
129
+ # Advance to next step
130
+ advanced = self.advance_from(self._pipeline, position.path)
131
+ if advanced is not None:
132
+ return advanced
133
+ # Past the end of the pipeline
134
+ return PipelineComplete(), position
135
+
136
+ # Fail: restart enclosing loop
137
+ restarted = self._restart_loop(self._pipeline, position.path)
138
+ if restarted is not None:
139
+ return restarted
140
+
141
+ # No enclosing loop — pipeline fails
142
+ reason = f"fail at step {list(position.path)} with no enclosing loop"
143
+ return PipelineFailed(reason=reason), position
144
+
145
+ def initial_position(self) -> PipelinePosition:
146
+ """Return the position of the first leaf step in the pipeline."""
147
+ path: list[int] = [0]
148
+ step: PipelineStep = self._pipeline[0]
149
+ while isinstance(step, LoopStep):
150
+ path.append(0)
151
+ step = step.steps[0]
152
+ return PipelinePosition(path=tuple(path))
153
+
154
+ # -----------------------------------------------------------------------
155
+ # Private helpers
156
+ # -----------------------------------------------------------------------
157
+
158
+ @staticmethod
159
+ def resolve_step(
160
+ steps: Sequence[PipelineStep],
161
+ path: tuple[int, ...],
162
+ ) -> PipelineStep:
163
+ """Walk the path to find the step at the given position."""
164
+ step = steps[path[0]]
165
+ for idx in path[1:]:
166
+ if not isinstance(step, LoopStep):
167
+ msg = f"Expected LoopStep at path prefix, got {type(step).__name__}"
168
+ raise ValueError(msg)
169
+ step = step.steps[idx]
170
+ return step
171
+
172
+ @staticmethod
173
+ def _find_step_by_role(
174
+ steps: Sequence[PipelineStep],
175
+ target_role: str,
176
+ ) -> int | None:
177
+ """Find the index of a step matching the target role name at the top level."""
178
+ for i, step in enumerate(steps):
179
+ if isinstance(step, RoleStep) and step.role == target_role:
180
+ return i
181
+ return None
182
+
183
+ @staticmethod
184
+ def _action_for_step(step: PipelineStep) -> PipelineAction:
185
+ """Return the immediate action for arriving at a step (no result yet)."""
186
+ if isinstance(step, RoleStep):
187
+ return SpawnRole(role=step.role)
188
+ if isinstance(step, GateStep):
189
+ return WaitForGate(gate=step.gate)
190
+ if isinstance(step, ActionStep):
191
+ return PerformAction(action=step.action)
192
+ # LoopStep — should not be called directly; caller descends into it.
193
+ msg = f"Cannot produce action for {type(step).__name__}"
194
+ raise ValueError(msg)
195
+
196
+ @staticmethod
197
+ def _descend_to_leaf(
198
+ steps: Sequence[PipelineStep],
199
+ prefix: tuple[int, ...],
200
+ index: int,
201
+ ) -> tuple[PipelineAction, PipelinePosition]:
202
+ """Descend from a step index, entering nested LoopSteps until we reach a leaf step.
203
+
204
+ Returns the action for the leaf step and its full position path.
205
+ """
206
+ step = steps[index]
207
+ current_path = (*prefix, index)
208
+
209
+ while isinstance(step, LoopStep):
210
+ # Enter the loop at its first child
211
+ current_path = (*current_path, 0)
212
+ step = step.steps[0]
213
+
214
+ return PipelineExecutor._action_for_step(step), PipelinePosition(path=current_path)
215
+
216
+ @staticmethod
217
+ def _descend_to_leaf_from_loop(
218
+ loop: LoopStep,
219
+ loop_path: tuple[int, ...],
220
+ ) -> tuple[PipelineAction, PipelinePosition]:
221
+ """Descend from a LoopStep to its first leaf step."""
222
+ step: PipelineStep = loop.steps[0]
223
+ current_path = (*loop_path, 0)
224
+
225
+ while isinstance(step, LoopStep):
226
+ current_path = (*current_path, 0)
227
+ step = step.steps[0]
228
+
229
+ return PipelineExecutor._action_for_step(step), PipelinePosition(path=current_path)
230
+
231
+ @classmethod
232
+ def advance_from(
233
+ cls,
234
+ pipeline: Sequence[PipelineStep],
235
+ path: tuple[int, ...],
236
+ ) -> tuple[PipelineAction, PipelinePosition] | None:
237
+ """Try to advance to the next sibling step at the given nesting level.
238
+
239
+ If we're at the end of the current step list, returns None (caller must
240
+ handle exiting to the parent level).
241
+ """
242
+ if len(path) == 1:
243
+ # Top level
244
+ next_idx = path[0] + 1
245
+ if next_idx >= len(pipeline):
246
+ return None
247
+ return cls._descend_to_leaf(pipeline, (), next_idx)
248
+
249
+ # Inside a nested structure — find the parent LoopStep
250
+ parent_path = path[:-1]
251
+ current_idx = path[-1]
252
+
253
+ # Walk to the parent step
254
+ parent_steps: Sequence[PipelineStep] = pipeline
255
+ for idx in parent_path[:-1]:
256
+ s = parent_steps[idx]
257
+ if not isinstance(s, LoopStep):
258
+ msg = f"Expected LoopStep, got {type(s).__name__}"
259
+ raise ValueError(msg)
260
+ parent_steps = s.steps
261
+
262
+ parent_step = parent_steps[parent_path[-1]]
263
+ if not isinstance(parent_step, LoopStep):
264
+ msg = f"Expected LoopStep, got {type(parent_step).__name__}"
265
+ raise ValueError(msg)
266
+
267
+ next_idx = current_idx + 1
268
+ if next_idx < len(parent_step.steps):
269
+ # There's a next sibling within this loop
270
+ return cls._descend_to_leaf(parent_step.steps, parent_path, next_idx)
271
+
272
+ # End of this loop's steps — exit the loop and advance at the parent level
273
+ return cls.advance_from(pipeline, parent_path)
274
+
275
+ @classmethod
276
+ def _restart_loop(
277
+ cls,
278
+ pipeline: Sequence[PipelineStep],
279
+ path: tuple[int, ...],
280
+ ) -> tuple[PipelineAction, PipelinePosition] | None:
281
+ """Find the nearest enclosing LoopStep and restart it.
282
+
283
+ Returns None if there is no enclosing loop (fail at top level).
284
+ """
285
+ # Walk up the path to find a LoopStep
286
+ for depth in range(len(path) - 1, 0, -1):
287
+ candidate_path = path[:depth]
288
+ step: PipelineStep
289
+ steps: Sequence[PipelineStep] = pipeline
290
+ step = steps[candidate_path[0]]
291
+ for idx in candidate_path[1:]:
292
+ if not isinstance(step, LoopStep):
293
+ break
294
+ step = step.steps[idx]
295
+ else:
296
+ # We successfully walked the path — check if it points to a LoopStep
297
+ if isinstance(step, LoopStep):
298
+ return cls._descend_to_leaf_from_loop(step, candidate_path)
299
+
300
+ # Also check if the top-level step itself is a LoopStep
301
+ if len(path) > 1:
302
+ top_step = pipeline[path[0]]
303
+ if isinstance(top_step, LoopStep):
304
+ return cls._descend_to_leaf_from_loop(top_step, (path[0],))
305
+
306
+ return None