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.
Files changed (72) hide show
  1. extensions/README.md +0 -0
  2. extensions/__init__.py +0 -0
  3. extensions/catalog/README.md +0 -0
  4. extensions/catalog/any_path.py +214 -0
  5. extensions/catalog/file_system.py +52 -0
  6. extensions/catalog/minio.py +72 -0
  7. extensions/catalog/pyproject.toml +14 -0
  8. extensions/catalog/s3.py +11 -0
  9. extensions/job_executor/README.md +0 -0
  10. extensions/job_executor/__init__.py +236 -0
  11. extensions/job_executor/emulate.py +70 -0
  12. extensions/job_executor/k8s.py +553 -0
  13. extensions/job_executor/k8s_job_spec.yaml +37 -0
  14. extensions/job_executor/local.py +35 -0
  15. extensions/job_executor/local_container.py +161 -0
  16. extensions/job_executor/pyproject.toml +16 -0
  17. extensions/nodes/README.md +0 -0
  18. extensions/nodes/__init__.py +0 -0
  19. extensions/nodes/conditional.py +301 -0
  20. extensions/nodes/fail.py +78 -0
  21. extensions/nodes/loop.py +394 -0
  22. extensions/nodes/map.py +477 -0
  23. extensions/nodes/parallel.py +281 -0
  24. extensions/nodes/pyproject.toml +15 -0
  25. extensions/nodes/stub.py +93 -0
  26. extensions/nodes/success.py +78 -0
  27. extensions/nodes/task.py +156 -0
  28. extensions/pipeline_executor/README.md +0 -0
  29. extensions/pipeline_executor/__init__.py +871 -0
  30. extensions/pipeline_executor/argo.py +1266 -0
  31. extensions/pipeline_executor/emulate.py +119 -0
  32. extensions/pipeline_executor/local.py +226 -0
  33. extensions/pipeline_executor/local_container.py +369 -0
  34. extensions/pipeline_executor/mocked.py +159 -0
  35. extensions/pipeline_executor/pyproject.toml +16 -0
  36. extensions/run_log_store/README.md +0 -0
  37. extensions/run_log_store/__init__.py +0 -0
  38. extensions/run_log_store/any_path.py +100 -0
  39. extensions/run_log_store/chunked_fs.py +122 -0
  40. extensions/run_log_store/chunked_minio.py +141 -0
  41. extensions/run_log_store/file_system.py +91 -0
  42. extensions/run_log_store/generic_chunked.py +549 -0
  43. extensions/run_log_store/minio.py +114 -0
  44. extensions/run_log_store/pyproject.toml +15 -0
  45. extensions/secrets/README.md +0 -0
  46. extensions/secrets/dotenv.py +62 -0
  47. extensions/secrets/pyproject.toml +15 -0
  48. runnable/__init__.py +108 -0
  49. runnable/catalog.py +141 -0
  50. runnable/cli.py +484 -0
  51. runnable/context.py +730 -0
  52. runnable/datastore.py +1058 -0
  53. runnable/defaults.py +159 -0
  54. runnable/entrypoints.py +390 -0
  55. runnable/exceptions.py +137 -0
  56. runnable/executor.py +561 -0
  57. runnable/gantt.py +1646 -0
  58. runnable/graph.py +501 -0
  59. runnable/names.py +546 -0
  60. runnable/nodes.py +593 -0
  61. runnable/parameters.py +217 -0
  62. runnable/pickler.py +96 -0
  63. runnable/sdk.py +1277 -0
  64. runnable/secrets.py +92 -0
  65. runnable/tasks.py +1268 -0
  66. runnable/telemetry.py +142 -0
  67. runnable/utils.py +423 -0
  68. runnable-0.50.0.dist-info/METADATA +189 -0
  69. runnable-0.50.0.dist-info/RECORD +72 -0
  70. runnable-0.50.0.dist-info/WHEEL +4 -0
  71. runnable-0.50.0.dist-info/entry_points.txt +53 -0
  72. runnable-0.50.0.dist-info/licenses/LICENSE +201 -0
@@ -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)