runnable 0.50.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.
- extensions/README.md +0 -0
- extensions/__init__.py +0 -0
- extensions/catalog/README.md +0 -0
- extensions/catalog/any_path.py +214 -0
- extensions/catalog/file_system.py +52 -0
- extensions/catalog/minio.py +72 -0
- extensions/catalog/pyproject.toml +14 -0
- extensions/catalog/s3.py +11 -0
- extensions/job_executor/README.md +0 -0
- extensions/job_executor/__init__.py +236 -0
- extensions/job_executor/emulate.py +70 -0
- extensions/job_executor/k8s.py +553 -0
- extensions/job_executor/k8s_job_spec.yaml +37 -0
- extensions/job_executor/local.py +35 -0
- extensions/job_executor/local_container.py +161 -0
- extensions/job_executor/pyproject.toml +16 -0
- extensions/nodes/README.md +0 -0
- extensions/nodes/__init__.py +0 -0
- extensions/nodes/conditional.py +301 -0
- extensions/nodes/fail.py +78 -0
- extensions/nodes/loop.py +394 -0
- extensions/nodes/map.py +477 -0
- extensions/nodes/parallel.py +281 -0
- extensions/nodes/pyproject.toml +15 -0
- extensions/nodes/stub.py +93 -0
- extensions/nodes/success.py +78 -0
- extensions/nodes/task.py +156 -0
- extensions/pipeline_executor/README.md +0 -0
- extensions/pipeline_executor/__init__.py +871 -0
- extensions/pipeline_executor/argo.py +1266 -0
- extensions/pipeline_executor/emulate.py +119 -0
- extensions/pipeline_executor/local.py +226 -0
- extensions/pipeline_executor/local_container.py +369 -0
- extensions/pipeline_executor/mocked.py +159 -0
- extensions/pipeline_executor/pyproject.toml +16 -0
- extensions/run_log_store/README.md +0 -0
- extensions/run_log_store/__init__.py +0 -0
- extensions/run_log_store/any_path.py +100 -0
- extensions/run_log_store/chunked_fs.py +122 -0
- extensions/run_log_store/chunked_minio.py +141 -0
- extensions/run_log_store/file_system.py +91 -0
- extensions/run_log_store/generic_chunked.py +549 -0
- extensions/run_log_store/minio.py +114 -0
- extensions/run_log_store/pyproject.toml +15 -0
- extensions/secrets/README.md +0 -0
- extensions/secrets/dotenv.py +62 -0
- extensions/secrets/pyproject.toml +15 -0
- runnable/__init__.py +108 -0
- runnable/catalog.py +141 -0
- runnable/cli.py +484 -0
- runnable/context.py +730 -0
- runnable/datastore.py +1058 -0
- runnable/defaults.py +159 -0
- runnable/entrypoints.py +390 -0
- runnable/exceptions.py +137 -0
- runnable/executor.py +561 -0
- runnable/gantt.py +1646 -0
- runnable/graph.py +501 -0
- runnable/names.py +546 -0
- runnable/nodes.py +593 -0
- runnable/parameters.py +217 -0
- runnable/pickler.py +96 -0
- runnable/sdk.py +1277 -0
- runnable/secrets.py +92 -0
- runnable/tasks.py +1268 -0
- runnable/telemetry.py +142 -0
- runnable/utils.py +423 -0
- runnable-0.50.0.dist-info/METADATA +189 -0
- runnable-0.50.0.dist-info/RECORD +72 -0
- runnable-0.50.0.dist-info/WHEEL +4 -0
- runnable-0.50.0.dist-info/entry_points.txt +53 -0
- runnable-0.50.0.dist-info/licenses/LICENSE +201 -0
extensions/nodes/loop.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, Dict, Optional, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, PrivateAttr
|
|
7
|
+
|
|
8
|
+
from runnable import defaults
|
|
9
|
+
from runnable.datastore import Parameter
|
|
10
|
+
from runnable.defaults import LOOP_PLACEHOLDER, IterableParameterModel, LoopIndexModel
|
|
11
|
+
from runnable.graph import Graph, create_graph
|
|
12
|
+
from runnable.nodes import CompositeNode
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(defaults.LOGGER_NAME)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoopNode(CompositeNode):
|
|
18
|
+
"""
|
|
19
|
+
A loop node that iterates over a branch until a break condition is met.
|
|
20
|
+
|
|
21
|
+
The branch executes repeatedly until either:
|
|
22
|
+
- parameters[break_on] == True
|
|
23
|
+
- max_iterations is reached
|
|
24
|
+
|
|
25
|
+
Each iteration gets its own branch log using LOOP_PLACEHOLDER pattern.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
node_type: str = Field(default="loop", serialization_alias="type")
|
|
29
|
+
|
|
30
|
+
# The sub-graph to execute repeatedly
|
|
31
|
+
branch: Graph
|
|
32
|
+
|
|
33
|
+
# Maximum iterations (safety limit)
|
|
34
|
+
max_iterations: int
|
|
35
|
+
|
|
36
|
+
# Boolean parameter name - when True, loop exits
|
|
37
|
+
break_on: str
|
|
38
|
+
|
|
39
|
+
# Environment variable name for iteration index (no prefix)
|
|
40
|
+
index_as: str
|
|
41
|
+
_should_exit: bool = PrivateAttr(default=False)
|
|
42
|
+
|
|
43
|
+
def get_summary(self) -> dict[str, Any]:
|
|
44
|
+
summary = {
|
|
45
|
+
"name": self.name,
|
|
46
|
+
"type": self.node_type,
|
|
47
|
+
"branch": self.branch.get_summary(),
|
|
48
|
+
"max_iterations": self.max_iterations,
|
|
49
|
+
"break_on": self.break_on,
|
|
50
|
+
"index_as": self.index_as,
|
|
51
|
+
}
|
|
52
|
+
return summary
|
|
53
|
+
|
|
54
|
+
def _get_iteration_branch_name(
|
|
55
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Get branch name for current iteration using placeholder resolution."""
|
|
58
|
+
# Create branch name template with loop placeholder
|
|
59
|
+
branch_template = f"{self.internal_name}.{LOOP_PLACEHOLDER}"
|
|
60
|
+
|
|
61
|
+
# Resolve using the refactored method
|
|
62
|
+
return self._resolve_iter_placeholders(branch_template, iter_variable)
|
|
63
|
+
|
|
64
|
+
def get_break_condition_value(
|
|
65
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
66
|
+
) -> bool:
|
|
67
|
+
"""Get the break condition parameter value from current iteration branch."""
|
|
68
|
+
# Get parameters from current iteration branch scope
|
|
69
|
+
current_branch_name = self._get_iteration_branch_name(iter_variable)
|
|
70
|
+
|
|
71
|
+
parameters: dict[str, Parameter] = self._context.run_log_store.get_parameters(
|
|
72
|
+
run_id=self._context.run_id, internal_branch_name=current_branch_name
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.break_on not in parameters:
|
|
76
|
+
return False # Default to continue if parameter doesn't exist
|
|
77
|
+
|
|
78
|
+
condition_value = parameters[self.break_on].get_value()
|
|
79
|
+
|
|
80
|
+
if not isinstance(condition_value, bool):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Break condition '{self.break_on}' must be boolean, "
|
|
83
|
+
f"got {type(condition_value).__name__}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return condition_value
|
|
87
|
+
|
|
88
|
+
def _create_iteration_branch_log(
|
|
89
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
90
|
+
):
|
|
91
|
+
"""Create branch log for the current iteration."""
|
|
92
|
+
branch_name = self._get_iteration_branch_name(iter_variable)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
branch_log = self._context.run_log_store.get_branch_log(
|
|
96
|
+
branch_name, self._context.run_id
|
|
97
|
+
)
|
|
98
|
+
logger.debug(f"Branch log already exists for {branch_name}")
|
|
99
|
+
except Exception: # BranchLogNotFoundError
|
|
100
|
+
branch_log = self._context.run_log_store.create_branch_log(branch_name)
|
|
101
|
+
logger.debug(f"Branch log created for {branch_name}")
|
|
102
|
+
|
|
103
|
+
branch_log.status = defaults.PROCESSING
|
|
104
|
+
self._context.run_log_store.add_branch_log(branch_log, self._context.run_id)
|
|
105
|
+
return branch_log
|
|
106
|
+
|
|
107
|
+
def _build_iteration_iter_variable(
|
|
108
|
+
self, parent_iter_variable: Optional[IterableParameterModel], iteration: int
|
|
109
|
+
) -> IterableParameterModel:
|
|
110
|
+
"""Build iter_variable for current iteration."""
|
|
111
|
+
if parent_iter_variable:
|
|
112
|
+
iter_var = parent_iter_variable.model_copy(deep=True)
|
|
113
|
+
else:
|
|
114
|
+
iter_var = IterableParameterModel()
|
|
115
|
+
|
|
116
|
+
# Initialize loop_variable if None
|
|
117
|
+
if iter_var.loop_variable is None:
|
|
118
|
+
iter_var.loop_variable = []
|
|
119
|
+
|
|
120
|
+
# Add current iteration index
|
|
121
|
+
iter_var.loop_variable.append(LoopIndexModel(value=iteration))
|
|
122
|
+
|
|
123
|
+
return iter_var
|
|
124
|
+
|
|
125
|
+
def fan_out(self, iter_variable: Optional[IterableParameterModel] = None):
|
|
126
|
+
"""
|
|
127
|
+
Create branch log for current iteration and copy parameters.
|
|
128
|
+
|
|
129
|
+
For iteration 0: copy from parent scope
|
|
130
|
+
For iteration N: copy from previous iteration (N-1) scope
|
|
131
|
+
"""
|
|
132
|
+
# Create branch log for current iteration
|
|
133
|
+
self._create_iteration_branch_log(iter_variable)
|
|
134
|
+
|
|
135
|
+
# Determine current iteration from iter_variable
|
|
136
|
+
current_iteration = 0
|
|
137
|
+
if iter_variable and iter_variable.loop_variable:
|
|
138
|
+
current_iteration = iter_variable.loop_variable[-1].value
|
|
139
|
+
|
|
140
|
+
# Determine source of parameters
|
|
141
|
+
if current_iteration == 0:
|
|
142
|
+
# Copy from parent scope
|
|
143
|
+
source_branch_name = self.internal_branch_name
|
|
144
|
+
else:
|
|
145
|
+
# Copy from previous iteration
|
|
146
|
+
prev_iter_var = (
|
|
147
|
+
iter_variable.model_copy(deep=True)
|
|
148
|
+
if iter_variable
|
|
149
|
+
else IterableParameterModel()
|
|
150
|
+
)
|
|
151
|
+
if prev_iter_var.loop_variable is None:
|
|
152
|
+
prev_iter_var.loop_variable = []
|
|
153
|
+
# Replace last loop index with previous iteration
|
|
154
|
+
prev_iter_var.loop_variable[-1] = LoopIndexModel(
|
|
155
|
+
value=current_iteration - 1
|
|
156
|
+
)
|
|
157
|
+
source_branch_name = self._get_iteration_branch_name(prev_iter_var)
|
|
158
|
+
|
|
159
|
+
# Get source parameters
|
|
160
|
+
source_params = self._context.run_log_store.get_parameters(
|
|
161
|
+
run_id=self._context.run_id, internal_branch_name=source_branch_name
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Copy to current iteration branch
|
|
165
|
+
target_branch_name = self._get_iteration_branch_name(iter_variable)
|
|
166
|
+
self._context.run_log_store.set_parameters(
|
|
167
|
+
parameters=source_params,
|
|
168
|
+
run_id=self._context.run_id,
|
|
169
|
+
internal_branch_name=target_branch_name,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def execute_as_graph(self, iter_variable: Optional[IterableParameterModel] = None):
|
|
173
|
+
"""
|
|
174
|
+
Execute the loop locally.
|
|
175
|
+
|
|
176
|
+
This function implements the main loop execution logic:
|
|
177
|
+
1. Call fan_out() to set up iteration 0
|
|
178
|
+
2. Loop until break condition or max_iterations
|
|
179
|
+
3. For each iteration:
|
|
180
|
+
- Set iteration index environment variable
|
|
181
|
+
- Build iter_variable for current iteration
|
|
182
|
+
- Execute branch graph
|
|
183
|
+
- Check termination conditions with fan_in()
|
|
184
|
+
- Create next iteration if continuing
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
iter_variable: Optional iteration context from parent composite nodes
|
|
188
|
+
"""
|
|
189
|
+
# Initialize with iteration 0
|
|
190
|
+
iteration = 0
|
|
191
|
+
iteration_iter_variable = self._build_iteration_iter_variable(
|
|
192
|
+
iter_variable, iteration
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Set up iteration 0
|
|
196
|
+
self.fan_out(iter_variable=iteration_iter_variable)
|
|
197
|
+
|
|
198
|
+
while True:
|
|
199
|
+
# Set iteration index environment variable
|
|
200
|
+
os.environ[self.index_as] = str(iteration)
|
|
201
|
+
|
|
202
|
+
logger.debug(f"Executing loop iteration {iteration} for {self.name}")
|
|
203
|
+
|
|
204
|
+
# Execute the branch for this iteration
|
|
205
|
+
self._context.pipeline_executor.execute_graph(
|
|
206
|
+
self.branch, iter_variable=iteration_iter_variable
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Check termination conditions
|
|
210
|
+
self.fan_in(iter_variable=iteration_iter_variable)
|
|
211
|
+
|
|
212
|
+
if self._should_exit:
|
|
213
|
+
logger.debug(f"Loop {self.name} exiting after iteration {iteration}")
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
# Prepare for next iteration
|
|
217
|
+
iteration += 1
|
|
218
|
+
|
|
219
|
+
# Safety check - this should be caught by fan_in, but double-check
|
|
220
|
+
if iteration >= self.max_iterations:
|
|
221
|
+
logger.warning(
|
|
222
|
+
f"Loop {self.name} hit max_iterations safety limit: {self.max_iterations}"
|
|
223
|
+
)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
# Build iter_variable for next iteration and set it up
|
|
227
|
+
iteration_iter_variable = self._build_iteration_iter_variable(
|
|
228
|
+
iter_variable, iteration
|
|
229
|
+
)
|
|
230
|
+
self.fan_out(iter_variable=iteration_iter_variable)
|
|
231
|
+
|
|
232
|
+
def fan_in(self, iter_variable: Optional[IterableParameterModel] = None) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Check termination conditions and handle loop completion.
|
|
235
|
+
|
|
236
|
+
Checks in order:
|
|
237
|
+
1. Branch execution failure - if current iteration failed, exit with fail status
|
|
238
|
+
2. Break condition - if break_on parameter is True, exit with success status
|
|
239
|
+
3. Max iterations - if reached limit, exit with current branch status
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
None: Sets self._should_exit and handles status/parameter rollback
|
|
243
|
+
"""
|
|
244
|
+
# Get current iteration from iter_variable
|
|
245
|
+
current_iteration = 0
|
|
246
|
+
if iter_variable and iter_variable.loop_variable:
|
|
247
|
+
current_iteration = iter_variable.loop_variable[-1].value
|
|
248
|
+
|
|
249
|
+
# FIRST: Check if current iteration's branch execution failed
|
|
250
|
+
current_branch_name = self._get_iteration_branch_name(iter_variable)
|
|
251
|
+
try:
|
|
252
|
+
branch_log = self._context.run_log_store.get_branch_log(
|
|
253
|
+
current_branch_name, self._context.run_id
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# If branch execution failed, exit immediately with fail status
|
|
257
|
+
if branch_log.status != defaults.SUCCESS:
|
|
258
|
+
logger.debug(
|
|
259
|
+
f"Loop {self.name} exiting due to branch failure in iteration {current_iteration}"
|
|
260
|
+
)
|
|
261
|
+
self._rollback_parameters_to_parent(iter_variable)
|
|
262
|
+
self._set_step_status_to_fail(iter_variable)
|
|
263
|
+
self._should_exit = True
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
except Exception:
|
|
267
|
+
# If we can't get branch log, assume failure
|
|
268
|
+
logger.warning(
|
|
269
|
+
f"Loop {self.name} could not get branch log for {current_branch_name}, assuming failure"
|
|
270
|
+
)
|
|
271
|
+
self._rollback_parameters_to_parent(iter_variable)
|
|
272
|
+
self._set_step_status_to_fail(iter_variable)
|
|
273
|
+
self._should_exit = True
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
# SECOND: Check break condition (only if branch succeeded)
|
|
277
|
+
break_condition_met = False
|
|
278
|
+
try:
|
|
279
|
+
break_condition_met = self.get_break_condition_value(iter_variable)
|
|
280
|
+
except (KeyError, ValueError):
|
|
281
|
+
# If break parameter doesn't exist or invalid, continue
|
|
282
|
+
break_condition_met = False
|
|
283
|
+
|
|
284
|
+
# THIRD: Check max iterations (0-indexed, so iteration N means N+1 total iterations)
|
|
285
|
+
max_iterations_reached = current_iteration >= (self.max_iterations - 1)
|
|
286
|
+
|
|
287
|
+
should_exit = break_condition_met or max_iterations_reached
|
|
288
|
+
|
|
289
|
+
if should_exit:
|
|
290
|
+
# Roll back parameters to parent and set status based on branch success
|
|
291
|
+
self._rollback_parameters_to_parent(iter_variable)
|
|
292
|
+
self._set_final_step_status(iter_variable)
|
|
293
|
+
|
|
294
|
+
self._should_exit = should_exit
|
|
295
|
+
|
|
296
|
+
def _rollback_parameters_to_parent(
|
|
297
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
298
|
+
):
|
|
299
|
+
"""Copy parameters from current iteration back to parent scope."""
|
|
300
|
+
current_branch_name = self._get_iteration_branch_name(iter_variable)
|
|
301
|
+
|
|
302
|
+
current_params = self._context.run_log_store.get_parameters(
|
|
303
|
+
run_id=self._context.run_id, internal_branch_name=current_branch_name
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Copy back to parent
|
|
307
|
+
self._context.run_log_store.set_parameters(
|
|
308
|
+
parameters=current_params,
|
|
309
|
+
run_id=self._context.run_id,
|
|
310
|
+
internal_branch_name=self.internal_branch_name,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _set_final_step_status(
|
|
314
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
315
|
+
):
|
|
316
|
+
"""Set the loop node's final status based on branch execution."""
|
|
317
|
+
effective_internal_name = self._resolve_iter_placeholders(
|
|
318
|
+
self.internal_name, iter_variable=iter_variable
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
step_log = self._context.run_log_store.get_step_log(
|
|
322
|
+
effective_internal_name, self._context.run_id
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Check current iteration branch status
|
|
326
|
+
current_branch_name = self._get_iteration_branch_name(iter_variable)
|
|
327
|
+
try:
|
|
328
|
+
current_branch_log = self._context.run_log_store.get_branch_log(
|
|
329
|
+
current_branch_name, self._context.run_id
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if current_branch_log.status == defaults.SUCCESS:
|
|
333
|
+
step_log.status = defaults.SUCCESS
|
|
334
|
+
else:
|
|
335
|
+
step_log.status = defaults.FAIL
|
|
336
|
+
|
|
337
|
+
except Exception:
|
|
338
|
+
# If branch log not found, mark as failed
|
|
339
|
+
step_log.status = defaults.FAIL
|
|
340
|
+
|
|
341
|
+
self._context.run_log_store.add_step_log(step_log, self._context.run_id)
|
|
342
|
+
|
|
343
|
+
def _set_step_status_to_fail(
|
|
344
|
+
self, iter_variable: Optional[IterableParameterModel] = None
|
|
345
|
+
):
|
|
346
|
+
"""Set the loop node's status to FAIL when branch execution fails."""
|
|
347
|
+
effective_internal_name = self._resolve_iter_placeholders(
|
|
348
|
+
self.internal_name, iter_variable=iter_variable
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
step_log = self._context.run_log_store.get_step_log(
|
|
352
|
+
effective_internal_name, self._context.run_id
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
step_log.status = defaults.FAIL
|
|
356
|
+
self._context.run_log_store.add_step_log(step_log, self._context.run_id)
|
|
357
|
+
|
|
358
|
+
def _get_branch_by_name(self, branch_name: str) -> Graph: # noqa: ARG002
|
|
359
|
+
"""
|
|
360
|
+
Retrieve a branch by name.
|
|
361
|
+
|
|
362
|
+
For a loop node, we always return the single branch.
|
|
363
|
+
This method takes no responsibility in checking the validity of the naming.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
branch_name (str): The name of the branch to retrieve (unused, interface compatibility)
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Graph: The loop branch
|
|
370
|
+
"""
|
|
371
|
+
return self.branch
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def parse_from_config(cls, config: Dict[str, Any]) -> "LoopNode":
|
|
375
|
+
"""
|
|
376
|
+
Parse LoopNode from configuration dictionary.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
config: Configuration dictionary containing node settings
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
LoopNode: Configured loop node instance
|
|
383
|
+
"""
|
|
384
|
+
internal_name = cast(str, config.get("internal_name"))
|
|
385
|
+
|
|
386
|
+
config_branch = config.pop("branch", {})
|
|
387
|
+
if not config_branch:
|
|
388
|
+
raise Exception("A loop node should have a branch")
|
|
389
|
+
|
|
390
|
+
branch = create_graph(
|
|
391
|
+
deepcopy(config_branch),
|
|
392
|
+
internal_branch_name=internal_name + "." + LOOP_PLACEHOLDER,
|
|
393
|
+
)
|
|
394
|
+
return cls(branch=branch, **config)
|