highway-dsl 0.0.3__py3-none-any.whl → 1.0.3__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.
@@ -39,6 +39,9 @@ class BaseOperator(BaseModel, ABC):
39
39
  retry_policy: Optional[RetryPolicy] = None
40
40
  timeout_policy: Optional[TimeoutPolicy] = None
41
41
  metadata: Dict[str, Any] = Field(default_factory=dict)
42
+ is_internal_loop_task: bool = Field(
43
+ default=False, exclude=True
44
+ ) # Mark if task is internal to a loop
42
45
 
43
46
  model_config = ConfigDict(use_enum_values=True, arbitrary_types_allowed=True)
44
47
 
@@ -91,7 +94,16 @@ class ParallelOperator(BaseOperator):
91
94
 
92
95
  class ForEachOperator(BaseOperator):
93
96
  items: str
94
- task_chain: List[str] = Field(default_factory=list)
97
+ loop_body: List[
98
+ Union[
99
+ TaskOperator,
100
+ ConditionOperator,
101
+ WaitOperator,
102
+ ParallelOperator,
103
+ "ForEachOperator",
104
+ "WhileOperator",
105
+ ]
106
+ ] = Field(default_factory=list)
95
107
  operator_type: OperatorType = Field(OperatorType.FOREACH, frozen=True)
96
108
 
97
109
 
@@ -204,12 +216,30 @@ class WorkflowBuilder:
204
216
  self._current_task: Optional[str] = None
205
217
  self.parent = parent
206
218
 
219
+ def _add_task(
220
+ self,
221
+ task: Union[
222
+ TaskOperator,
223
+ ConditionOperator,
224
+ WaitOperator,
225
+ ParallelOperator,
226
+ ForEachOperator,
227
+ WhileOperator,
228
+ ],
229
+ **kwargs,
230
+ ) -> None:
231
+ dependencies = kwargs.get("dependencies", [])
232
+ if self._current_task and not dependencies:
233
+ dependencies.append(self._current_task)
234
+
235
+ task.dependencies = sorted(list(set(dependencies)))
236
+
237
+ self.workflow.add_task(task)
238
+ self._current_task = task.task_id
239
+
207
240
  def task(self, task_id: str, function: str, **kwargs) -> "WorkflowBuilder":
208
241
  task = TaskOperator(task_id=task_id, function=function, **kwargs)
209
- if self._current_task:
210
- task.dependencies.append(self._current_task)
211
- self.workflow.add_task(task)
212
- self._current_task = task_id
242
+ self._add_task(task, **kwargs)
213
243
  return self
214
244
 
215
245
  def condition(
@@ -220,8 +250,8 @@ class WorkflowBuilder:
220
250
  if_false: Callable[["WorkflowBuilder"], "WorkflowBuilder"],
221
251
  **kwargs,
222
252
  ) -> "WorkflowBuilder":
223
- true_builder = if_true(WorkflowBuilder(f"{{task_id}}_true", parent=self))
224
- false_builder = if_false(WorkflowBuilder(f"{{task_id}}_false", parent=self))
253
+ true_builder = if_true(WorkflowBuilder(f"{task_id}_true", parent=self))
254
+ false_builder = if_false(WorkflowBuilder(f"{task_id}_false", parent=self))
225
255
 
226
256
  true_tasks = list(true_builder.workflow.tasks.keys())
227
257
  false_tasks = list(false_builder.workflow.tasks.keys())
@@ -234,14 +264,17 @@ class WorkflowBuilder:
234
264
  **kwargs,
235
265
  )
236
266
 
237
- if self._current_task:
238
- task.dependencies.append(self._current_task)
239
-
240
- self.workflow.add_task(task)
267
+ self._add_task(task, **kwargs)
241
268
 
242
269
  for task_obj in true_builder.workflow.tasks.values():
270
+ # Only add the condition task as dependency, preserve original dependencies
271
+ if task_id not in task_obj.dependencies:
272
+ task_obj.dependencies.append(task_id)
243
273
  self.workflow.add_task(task_obj)
244
274
  for task_obj in false_builder.workflow.tasks.values():
275
+ # Only add the condition task as dependency, preserve original dependencies
276
+ if task_id not in task_obj.dependencies:
277
+ task_obj.dependencies.append(task_id)
245
278
  self.workflow.add_task(task_obj)
246
279
 
247
280
  self._current_task = task_id
@@ -251,10 +284,7 @@ class WorkflowBuilder:
251
284
  self, task_id: str, wait_for: Union[timedelta, datetime, str], **kwargs
252
285
  ) -> "WorkflowBuilder":
253
286
  task = WaitOperator(task_id=task_id, wait_for=wait_for, **kwargs)
254
- if self._current_task:
255
- task.dependencies.append(self._current_task)
256
- self.workflow.add_task(task)
257
- self._current_task = task_id
287
+ self._add_task(task, **kwargs)
258
288
  return self
259
289
 
260
290
  def parallel(
@@ -263,10 +293,12 @@ class WorkflowBuilder:
263
293
  branches: Dict[str, Callable[["WorkflowBuilder"], "WorkflowBuilder"]],
264
294
  **kwargs,
265
295
  ) -> "WorkflowBuilder":
266
- branch_builders = {
267
- name: branch_func(WorkflowBuilder(f"{{task_id}}_{{name}}", parent=self))
268
- for name, branch_func in branches.items()
269
- }
296
+ branch_builders = {}
297
+ for name, branch_func in branches.items():
298
+ branch_builder = branch_func(
299
+ WorkflowBuilder(f"{task_id}_{name}", parent=self)
300
+ )
301
+ branch_builders[name] = branch_builder
270
302
 
271
303
  branch_tasks = {
272
304
  name: list(builder.workflow.tasks.keys())
@@ -275,27 +307,60 @@ class WorkflowBuilder:
275
307
 
276
308
  task = ParallelOperator(task_id=task_id, branches=branch_tasks, **kwargs)
277
309
 
278
- if self._current_task:
279
- task.dependencies.append(self._current_task)
280
-
281
- self.workflow.add_task(task)
310
+ self._add_task(task, **kwargs)
282
311
 
283
312
  for builder in branch_builders.values():
284
313
  for task_obj in builder.workflow.tasks.values():
314
+ # Only add the parallel task as dependency to non-internal tasks,
315
+ # preserve original dependencies
316
+ if (
317
+ not getattr(task_obj, "is_internal_loop_task", False)
318
+ and task_id not in task_obj.dependencies
319
+ ):
320
+ task_obj.dependencies.append(task_id)
285
321
  self.workflow.add_task(task_obj)
286
322
 
287
323
  self._current_task = task_id
288
324
  return self
289
325
 
290
326
  def foreach(
291
- self, task_id: str, items: str, task_chain: List[str], **kwargs
327
+ self,
328
+ task_id: str,
329
+ items: str,
330
+ loop_body: Callable[["WorkflowBuilder"], "WorkflowBuilder"],
331
+ **kwargs,
292
332
  ) -> "WorkflowBuilder":
333
+ # Create a temporary builder for the loop body.
334
+ temp_builder = WorkflowBuilder(f"{task_id}_loop", parent=self)
335
+ loop_builder = loop_body(temp_builder)
336
+ loop_tasks = list(loop_builder.workflow.tasks.values())
337
+
338
+ # Mark all loop body tasks as internal to prevent parallel dependency injection
339
+ for task_obj in loop_tasks:
340
+ task_obj.is_internal_loop_task = True
341
+
342
+ # Create the foreach operator
293
343
  task = ForEachOperator(
294
- task_id=task_id, items=items, task_chain=task_chain, **kwargs
344
+ task_id=task_id,
345
+ items=items,
346
+ loop_body=loop_tasks,
347
+ **kwargs,
295
348
  )
296
- if self._current_task:
297
- task.dependencies.append(self._current_task)
298
- self.workflow.add_task(task)
349
+
350
+ # Add the foreach task to workflow to establish initial dependencies
351
+ self._add_task(task, **kwargs)
352
+
353
+ # Add the foreach task as dependency to the FIRST task in the loop body
354
+ # and preserve the original dependency chain within the loop
355
+ if loop_tasks:
356
+ first_task = loop_tasks[0]
357
+ if task_id not in first_task.dependencies:
358
+ first_task.dependencies.append(task_id)
359
+
360
+ # Add all loop tasks to workflow
361
+ for task_obj in loop_tasks:
362
+ self.workflow.add_task(task_obj)
363
+
299
364
  self._current_task = task_id
300
365
  return self
301
366
 
@@ -306,9 +371,13 @@ class WorkflowBuilder:
306
371
  loop_body: Callable[["WorkflowBuilder"], "WorkflowBuilder"],
307
372
  **kwargs,
308
373
  ) -> "WorkflowBuilder":
309
- loop_builder = loop_body(WorkflowBuilder(f"{{task_id}}_loop", parent=self))
374
+ loop_builder = loop_body(WorkflowBuilder(f"{task_id}_loop", parent=self))
310
375
  loop_tasks = list(loop_builder.workflow.tasks.values())
311
376
 
377
+ # Mark all loop body tasks as internal to prevent parallel dependency injection
378
+ for task_obj in loop_tasks:
379
+ task_obj.is_internal_loop_task = True
380
+
312
381
  task = WhileOperator(
313
382
  task_id=task_id,
314
383
  condition=condition,
@@ -316,13 +385,18 @@ class WorkflowBuilder:
316
385
  **kwargs,
317
386
  )
318
387
 
319
- if self._current_task:
320
- task.dependencies.append(self._current_task)
388
+ self._add_task(task, **kwargs)
321
389
 
322
- self.workflow.add_task(task)
390
+ # Fix: Only add the while task as dependency to the FIRST task in the loop body
391
+ # and preserve the original dependency chain within the loop
392
+ if loop_tasks:
393
+ first_task = loop_tasks[0]
394
+ if task_id not in first_task.dependencies:
395
+ first_task.dependencies.append(task_id)
323
396
 
324
- for task_obj in loop_tasks:
325
- self.workflow.add_task(task_obj)
397
+ # Add all loop tasks to workflow without modifying their dependencies further
398
+ for task_obj in loop_tasks:
399
+ self.workflow.add_task(task_obj)
326
400
 
327
401
  self._current_task = task_id
328
402
  return self
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: highway_dsl
3
- Version: 0.0.3
4
- Summary: A domain specific language (DSL) for defining and managing data processing pipelines.
3
+ Version: 1.0.3
4
+ Summary: A stable domain specific language (DSL) for defining and managing data processing pipelines and workflow engines.
5
5
  Author-email: Farseed Ashouri <farseed.ashouri@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/rodmena-limited/highway_dsl
@@ -25,21 +25,29 @@ Dynamic: license-file
25
25
 
26
26
  [![PyPI version](https://badge.fury.io/py/highway-dsl.svg)](https://badge.fury.io/py/highway-dsl)
27
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
+ [![Stable](https://img.shields.io/badge/Status-Stable-brightgreen)](https://pypi.org/project/highway-dsl/)
29
+ [![Publish to PyPI](https://github.com/rodmena-limited/highway_dsl/actions/workflows/publish.yml/badge.svg)](https://github.com/rodmena-limited/highway_dsl/actions/workflows/publish.yml)
28
30
 
29
31
  **Highway DSL** is a Python-based domain-specific language for defining complex workflows in a clear, concise, and fluent manner. It is part of the larger **Highway** project, an advanced workflow engine capable of running complex DAG-based workflows.
30
32
 
33
+ ## Version 1.0.3 - Stable Release
34
+
35
+ This is a stable release with important bug fixes and enhancements, including a critical fix for the ForEach operator dependency management issue.
36
+
31
37
  ## Features
32
38
 
33
39
  * **Fluent API:** A powerful and intuitive `WorkflowBuilder` for defining workflows programmatically.
34
40
  * **Pydantic-based:** All models are built on Pydantic, providing robust data validation, serialization, and documentation.
35
41
  * **Rich Operators:** A comprehensive set of operators for handling various workflow scenarios:
36
- * `Task`
37
- * `Condition` (if/else)
38
- * `Parallel`
39
- * `ForEach`
40
- * `Wait`
41
- * `While`
42
+ * `Task` - Basic workflow steps
43
+ * `Condition` (if/else) - Conditional branching
44
+ * `Parallel` - Execute multiple branches simultaneously
45
+ * `ForEach` - Iterate over collections with proper dependency management
46
+ * `Wait` - Pause execution for scheduled tasks
47
+ * `While` - Execute loops based on conditions
48
+ * **Fixed ForEach Bug:** Proper encapsulation of loop body tasks to prevent unwanted "grandparent" dependencies from containing parallel operators.
42
49
  * **YAML/JSON Interoperability:** Workflows can be defined in Python and exported to YAML or JSON, and vice-versa.
50
+ * **Retry and Timeout Policies:** Built-in error handling and execution time management.
43
51
  * **Extensible:** The DSL is designed to be extensible with custom operators and policies.
44
52
 
45
53
  ## Installation
@@ -135,6 +143,63 @@ builder.task("finalize_product", "workflows.tasks.finalize_product", dependencie
135
143
  workflow = builder.build()
136
144
  ```
137
145
 
146
+ ### For-Each Loops with Proper Dependency Management
147
+
148
+ Fixed bug where foreach loops were incorrectly inheriting dependencies from containing parallel operators:
149
+
150
+ ```python
151
+ # This loop now properly encapsulates its internal tasks
152
+ builder.foreach(
153
+ "process_items",
154
+ items="{{data.items}}",
155
+ loop_body=lambda fb: fb.task("process_item", "processor.handle_item", args=["{{item.id}}"])
156
+ # Loop body tasks only have proper dependencies, not unwanted "grandparent" dependencies
157
+ )
158
+ ```
159
+
160
+ ### Retry Policies
161
+
162
+ ```python
163
+ from highway_dsl import RetryPolicy
164
+ from datetime import timedelta
165
+
166
+ builder.task(
167
+ "reliable_task",
168
+ "service.operation",
169
+ retry_policy=RetryPolicy(
170
+ max_retries=5,
171
+ delay=timedelta(seconds=10),
172
+ backoff_factor=2.0
173
+ )
174
+ )
175
+ ```
176
+
177
+ ### Timeout Policies
178
+
179
+ ```python
180
+ from highway_dsl import TimeoutPolicy
181
+ from datetime import timedelta
182
+
183
+ builder.task(
184
+ "timed_task",
185
+ "service.operation",
186
+ timeout_policy=TimeoutPolicy(
187
+ timeout=timedelta(hours=1),
188
+ kill_on_timeout=True
189
+ )
190
+ )
191
+ ```
192
+
193
+ ## What's New in Version 1.0.2
194
+
195
+ ### Bug Fixes
196
+ * **Fixed ForEach Operator Bug**: Resolved issue where foreach loops were incorrectly getting "grandparent" dependencies from containing parallel operators. Loop body tasks are now properly encapsulated and only depend on their parent loop operator and internal chain dependencies.
197
+
198
+ ### Enhancements
199
+ * **Improved Loop Dependency Management**: While loops and ForEach loops now properly encapsulate their internal dependencies without being affected by containing parallel operators.
200
+ * **Better Error Handling**: Enhanced error handling throughout the DSL.
201
+ * **Comprehensive Test Suite**: Added functional tests for all example workflows to ensure consistency.
202
+
138
203
  ## Development
139
204
 
140
205
  To set up the development environment:
@@ -158,3 +223,7 @@ pytest
158
223
  ```bash
159
224
  mypy .
160
225
  ```
226
+
227
+ ## License
228
+
229
+ MIT License
@@ -0,0 +1,7 @@
1
+ highway_dsl/__init__.py,sha256=mr1oMylxliFwu2VO2qpyM3sVQwYIoPL2P6JE-6ZuF7M,507
2
+ highway_dsl/workflow_dsl.py,sha256=bhCKDPrMaIkEI4HduKoeqd2VlZsK8wjr8RURifPufGU,14700
3
+ highway_dsl-1.0.3.dist-info/licenses/LICENSE,sha256=qdFq1H66BvKg67mf4-WGpFwtG2u_dNknxuJDQ1_ubaY,1072
4
+ highway_dsl-1.0.3.dist-info/METADATA,sha256=8rf2NIvAJ5pgDXzs9r2VjgePNqXTOlJbOL0nHRTvsSg,7366
5
+ highway_dsl-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ highway_dsl-1.0.3.dist-info/top_level.txt,sha256=_5uX-bbBsQ2rsi1XMr7WRyKbr6ack5GqVBcy-QjF1C8,12
7
+ highway_dsl-1.0.3.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- highway_dsl/__init__.py,sha256=mr1oMylxliFwu2VO2qpyM3sVQwYIoPL2P6JE-6ZuF7M,507
2
- highway_dsl/workflow_dsl.py,sha256=yMTmFr5bbjxfVTleCvSsDZ__n9C7qH39RdzajkUEmiI,11882
3
- highway_dsl-0.0.3.dist-info/licenses/LICENSE,sha256=qdFq1H66BvKg67mf4-WGpFwtG2u_dNknxuJDQ1_ubaY,1072
4
- highway_dsl-0.0.3.dist-info/METADATA,sha256=--TFErjeBDZ1mAyNHk30CQavLuKWAaoxgHK7xFpT-Ok,4612
5
- highway_dsl-0.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- highway_dsl-0.0.3.dist-info/top_level.txt,sha256=_5uX-bbBsQ2rsi1XMr7WRyKbr6ack5GqVBcy-QjF1C8,12
7
- highway_dsl-0.0.3.dist-info/RECORD,,