marsh-lib 0.2.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 (44) hide show
  1. marsh/__init__.py +2 -0
  2. marsh/bash/__init__.py +3 -0
  3. marsh/bash/bash_factory.py +74 -0
  4. marsh/bash/bash_grammar.py +44 -0
  5. marsh/bash/bash_runner_decorators.py +0 -0
  6. marsh/bash/bash_script.py +71 -0
  7. marsh/constants.py +5 -0
  8. marsh/core/__init__.py +8 -0
  9. marsh/core/authenticator.py +7 -0
  10. marsh/core/cmd_run_decorator.py +289 -0
  11. marsh/core/command_grammar.py +45 -0
  12. marsh/core/connector.py +77 -0
  13. marsh/core/conveyor.py +105 -0
  14. marsh/core/executor.py +296 -0
  15. marsh/core/expression.py +208 -0
  16. marsh/core/script.py +38 -0
  17. marsh/dag/__init__.py +11 -0
  18. marsh/dag/dag.py +748 -0
  19. marsh/dag/node.py +52 -0
  20. marsh/dag/startable.py +48 -0
  21. marsh/docker/__init__.py +2 -0
  22. marsh/docker/docker_command_grammar.py +31 -0
  23. marsh/docker/docker_executor.py +181 -0
  24. marsh/exceptions.py +16 -0
  25. marsh/logger.py +116 -0
  26. marsh/modifier_functions/__init__.py +2 -0
  27. marsh/modifier_functions/case_conversion.py +17 -0
  28. marsh/modifier_functions/readers.py +11 -0
  29. marsh/powershell/__init__.py +0 -0
  30. marsh/processor_functions/__init__.py +3 -0
  31. marsh/processor_functions/printers.py +63 -0
  32. marsh/processor_functions/raisers.py +8 -0
  33. marsh/processor_functions/redirections.py +39 -0
  34. marsh/signals.py +1 -0
  35. marsh/ssh/__init__.py +3 -0
  36. marsh/ssh/ssh_command_grammar.py +50 -0
  37. marsh/ssh/ssh_connector.py +71 -0
  38. marsh/ssh/ssh_factory.py +93 -0
  39. marsh/utils/__init__.py +16 -0
  40. marsh/utils/output_streams.py +13 -0
  41. marsh_lib-0.2.0.dist-info/LICENSE +21 -0
  42. marsh_lib-0.2.0.dist-info/METADATA +380 -0
  43. marsh_lib-0.2.0.dist-info/RECORD +44 -0
  44. marsh_lib-0.2.0.dist-info/WHEEL +4 -0
marsh/dag/dag.py ADDED
@@ -0,0 +1,748 @@
1
+ import asyncio
2
+ import threading
3
+ import multiprocessing
4
+ from concurrent.futures import as_completed, Future, ThreadPoolExecutor, ProcessPoolExecutor
5
+ from graphlib import TopologicalSorter
6
+ from typing import Dict, Optional, Set, List, Tuple, Any
7
+
8
+ from .startable import Startable
9
+
10
+
11
+ class Dag(Startable):
12
+ """
13
+ Base class representing a Directed Acyclic Graph (DAG) of Startable objects.
14
+
15
+ The `Dag` class manages a collection of `Startable` objects and defines their dependencies. Subclasses
16
+ of `Dag` implement different strategies for executing the startable objects (e.g., synchronously, asynchronously,
17
+ using threads or processes).
18
+
19
+ Attributes:
20
+ _startables (dict): A dictionary mapping startable names to `Startable` objects.
21
+ _graph (dict): A dictionary representing the dependency graph of startables.
22
+ _sorter (Optional[TopologicalSorter]): A topological sorter used to order the startables based on dependencies.
23
+ _last_startable (Optional[List[Startable]]): The list of the most recently added startables.
24
+
25
+ Methods:
26
+ add(dependent, *dependencies): Adds a dependent `Startable` and its dependencies to the DAG.
27
+ remove(startable): Removes a `Startable` from the DAG.
28
+ do(*startables): Adds the specified startables to the DAG and marks them as the last startables.
29
+ then(*dependents): Specifies the dependents of the last startables, adding them to the DAG.
30
+ when(*dependencies): Specifies the dependencies for the last startables.
31
+ start(): Starts the DAG execution (method to be overriden by subclasses).
32
+ """
33
+
34
+ def __init__(self, name):
35
+ """
36
+ Initializes a DAG with the specified name.
37
+
38
+ Args:
39
+ name (str): The name of the DAG.
40
+ """
41
+ super().__init__(name)
42
+ self._startables: Dict[str, Startable] = {}
43
+ self._graph: Dict[str, Set[str]] = {}
44
+ self._sorter: Optional[TopologicalSorter] = None
45
+ self._last_startable: Optional[List[Startable]] = []
46
+
47
+ @property
48
+ def startables(self) -> List[Startable]:
49
+ """
50
+ Returns a list of all `Startable` objects in the DAG.
51
+
52
+ Returns:
53
+ List[Startable]: A list of all `Startable` objects managed by the DAG.
54
+ """
55
+ return list(self._startables.values())
56
+
57
+ @property
58
+ def names(self) -> List[str]:
59
+ """
60
+ Returns a list of the names of all `Startable` objects in the DAG.
61
+
62
+ Returns:
63
+ List[str]: A list of names of all `Startable` objects.
64
+ """
65
+ return list(self._startables.keys())
66
+
67
+ @property
68
+ def sorted_names(self) -> List[str]:
69
+ """
70
+ Returns a list of startable names in topologically sorted order.
71
+
72
+ This property depends on the dependency graph of the startables.
73
+
74
+ Returns:
75
+ List[str]: A list of startable names sorted by their dependencies.
76
+ """
77
+ sorter = self.sorter
78
+ if sorter is None: return []
79
+ return list(sorter.static_order())
80
+
81
+ @property
82
+ def sorter(self) -> Optional[TopologicalSorter]:
83
+ """
84
+ Returns a TopologicalSorter instance based on the DAG's dependency graph.
85
+
86
+ This property computes a `TopologicalSorter` when the graph is not empty.
87
+
88
+ Returns:
89
+ Optional[TopologicalSorter]: The topological sorter instance or `None` if the graph is empty.
90
+ """
91
+ return TopologicalSorter(graph=self._graph) if self._graph else None
92
+
93
+ @property
94
+ def graph(self) -> Dict[str, Set[str]]:
95
+ """
96
+ Returns the dependency graph of startables in the DAG.
97
+
98
+ The graph is represented as a dictionary, where each key is the dependent startable name, and each value
99
+ is a set of names of other startable dependencies of the dependent startable.
100
+
101
+ Returns:
102
+ Dict[str, Set[str]]: The dependency graph of startables.
103
+ """
104
+ return self._graph
105
+
106
+ def reset(self) -> None:
107
+ """
108
+ Resets the DAG, clearing all startables and dependencies.
109
+
110
+ This method clears the `_startables`, `_graph`, and `_last_startable` attributes.
111
+ It also resets the topological sorter.
112
+ """
113
+ self._startables.clear()
114
+ self._sorter = TopologicalSorter()
115
+ self._graph.clear()
116
+ self._last_startable = []
117
+
118
+ def add(self, dependent: Startable, *dependencies: Startable) -> "Dag":
119
+ """
120
+ Adds a dependent `Startable` and its dependencies to the DAG.
121
+
122
+ Args:
123
+ dependent (Startable): The `Startable` to be added as a dependent in the DAG.
124
+ *dependencies (Startable): The `Startable` objects that the dependent depends on.
125
+
126
+ Returns:
127
+ Dag: The updated `Dag` instance with the newly added dependent and its dependencies.
128
+ """
129
+ # Update the Startable Dictionary
130
+ self._startables[dependent.name] = dependent
131
+
132
+ for dependency in dependencies:
133
+ self._startables[dependency.name] = dependency
134
+
135
+ # Make sure that if the dependency is not yet registered to the graph,
136
+ # then add them with with default empty set for their own dependencies.
137
+ self._graph[dependency.name] = self._graph.get(dependency.name, set())
138
+
139
+ # Update the Graph Dictionary
140
+ # dependency_set: set[str] = self._graph.get(dependent.name, set())
141
+ # self._graph[dependent.name] = dependency_set | set([dependency.name for dependency in dependencies])
142
+ self._graph.setdefault(dependent.name, set()).update([d.name for d in dependencies])
143
+
144
+ return self
145
+
146
+ def remove(self, startable: Startable) -> None:
147
+ """
148
+ Removes a `Startable` from the DAG.
149
+
150
+ Args:
151
+ startable (Startable): The `Startable` to be removed from the DAG.
152
+ """
153
+ try:
154
+ del self._startables[startable.name]
155
+ except KeyError:
156
+ pass
157
+
158
+ # Remove the startable from Graph Dictionary
159
+ try:
160
+ del self._graph[startable.name]
161
+ except KeyError:
162
+ pass
163
+
164
+ # Remove the startable from some dependency set of other startable in graph dictionary
165
+ for name, dependency_set in self._graph.items():
166
+ if startable.name in dependency_set:
167
+ self._graph[name].remove(startable.name)
168
+
169
+ def do(self, *startables: Startable) -> "Dag":
170
+ """
171
+ Adds the specified `Startable` objects to the DAG and marks them as the last added startables.
172
+
173
+ Args:
174
+ *startables (Startable): The `Startable` objects to be added to the DAG.
175
+
176
+ Returns:
177
+ Dag: The updated `Dag` instance with the newly added startables.
178
+ """
179
+ for startable in startables:
180
+ self.add(startable)
181
+
182
+ self._last_startable = startables
183
+ return self
184
+
185
+ def then(self, *dependents: Startable) -> "Dag":
186
+ """
187
+ Specifies the dependents of the last added startables, adding them to the DAG.
188
+
189
+ Args:
190
+ *dependents (Startable): The `Startable` objects that depend on the last added startables.
191
+
192
+ Returns:
193
+ Dag: The updated `Dag` instance with the newly added dependents.
194
+ """
195
+ assert dependents, "Dependents cannot be empty."
196
+
197
+ for dependent in dependents:
198
+ self.add(dependent, *self._last_startable)
199
+
200
+ self._last_startable = dependents # Updated to avoid side effects
201
+ return self
202
+
203
+ def when(self, *dependencies: Startable) -> "Dag":
204
+ """
205
+ Specifies the dependencies for the last added startables.
206
+
207
+ Args:
208
+ *dependencies (Startable): The `Startable` objects that the last added startables depend on.
209
+
210
+ Returns:
211
+ Dag: The updated `Dag` instance with the newly added dependencies.
212
+ """
213
+ assert dependencies, "Dependencies cannot be empty."
214
+
215
+ for dependent in self._last_startable:
216
+ self.add(dependent, *dependencies)
217
+
218
+ return self
219
+
220
+ def start(self) -> Dict[str, Tuple[bytes, bytes] | dict]:
221
+ """
222
+ Starts the DAG execution.
223
+
224
+ This method should be implemented by subclasses to define how the startables are executed.
225
+
226
+ Raises:
227
+ NotImplementedError: This method must be implemented by subclasses.
228
+ """
229
+ raise NotImplementedError("Add implementation to Dag.start")
230
+
231
+
232
+ class SyncDag(Dag):
233
+ """
234
+ A synchronous implementation of the `Dag` class.
235
+
236
+ The `SyncDag` class executes all startable objects in the DAG sequentially, ensuring that each startable
237
+ is executed only after its dependencies have been completed.
238
+
239
+ Methods:
240
+ start(): Executes the startables sequentially and returns the results as a dictionary.
241
+ """
242
+
243
+ def start(self) -> Dict[str, Any]:
244
+ """
245
+ Starts the DAG execution synchronously.
246
+
247
+ Executes each startable in topologically sorted order and returns the results in a dictionary.
248
+
249
+ Returns:
250
+ Dict[str, Any]: A dictionary mapping startable names to their results.
251
+ """
252
+ results = {}
253
+ for name in self.sorted_names:
254
+ startable = self._startables[name]
255
+ result = startable.start()
256
+ results[startable.name] = result
257
+
258
+ return results
259
+
260
+
261
+ class AsyncDag(Dag):
262
+ """
263
+ An asynchronous implementation of the `Dag` class using asyncio.
264
+
265
+ The `AsyncDag` class executes startable objects concurrently, using asyncio to manage coroutines and tasks
266
+ for parallel execution, while respecting the dependencies defined by the DAG structure.
267
+
268
+ Attributes:
269
+ _max_coroutines (int): The maximum number of concurrent coroutines allowed.
270
+ _timeout (float): The maximum time (in seconds) to wait for each startable.
271
+
272
+ Methods:
273
+ _worker(sorter, startable, semaphore, lock): Worker function that runs the startable asynchronously.
274
+ _main_loop(): Main loop that manages execution and ensures tasks are completed in dependency order.
275
+ start(): Starts the DAG execution asynchronously and returns the results as a dictionary.
276
+ """
277
+
278
+ def __init__(self, name: str, max_coroutines: int = 20, timeout: float = 600):
279
+ """
280
+ Initializes a AsyncDag with the specified name, maximum number of coroutines, and timeout.
281
+
282
+ Args:
283
+ name (str): The name of the AsyncDag.
284
+ max_coroutines (int, optional): The maximum number of concurrent coroutines. Defaults to 20.
285
+ timeout (float, optional): The time limit (seconds) for running a startable. Defaults to 600.
286
+ """
287
+ super().__init__(name)
288
+ self._max_coroutines = max_coroutines
289
+ self._timeout = timeout
290
+
291
+ async def _worker(self,
292
+ sorter: TopologicalSorter,
293
+ startable: Startable,
294
+ semaphore: asyncio.Semaphore,
295
+ lock: asyncio.Lock,
296
+ ) -> Dict[str, Any] | Tuple[bytes, bytes]:
297
+ """
298
+ Worker function to execute a startable asynchronously with a semaphore.
299
+
300
+ Args:
301
+ sorter (TopologicalSorter): The sorter to update as tasks are completed.
302
+ startable (Startable): The startable to execute.
303
+ semaphore (asyncio.Semaphore): Limits the number of concurrent coroutines.
304
+ lock (asyncio.Lock): Ensures thread safety when updating the sorter.
305
+
306
+ Returns:
307
+ Tuple[str, Any]: A tuple containing the startable name and its result.
308
+ """
309
+
310
+ async with semaphore:
311
+ try:
312
+ result = await asyncio.wait_for(asyncio.to_thread(startable.start), self._timeout)
313
+ except TimeoutError:
314
+ raise TimeoutError(f"{startable.name} exceeded {self._timeout} seconds.")
315
+
316
+ # Ensure only one task updates the TopologicalSorter at a time
317
+ async with lock:
318
+ sorter.done(startable.name)
319
+
320
+ return startable.name, result
321
+
322
+ async def _main_loop(self) -> Dict[str, Any]:
323
+ """
324
+ Main loop to execute startables concurrently in topological order.
325
+
326
+ Returns:
327
+ Dict[str, Any]: A dictionary mapping startable names to their results.
328
+ """
329
+ sorter = self.sorter
330
+ sorter.prepare()
331
+
332
+ semaphore = asyncio.Semaphore(self._max_coroutines) # Limit concurrent coroutines
333
+ lock = asyncio.Lock()
334
+ tasks = []
335
+ results = []
336
+
337
+ while sorter.is_active():
338
+ ready_names = sorter.get_ready()
339
+ for name in ready_names:
340
+ startable = self._startables[name]
341
+ task = asyncio.create_task(self._worker(sorter, startable, semaphore, lock))
342
+ tasks.append(task)
343
+
344
+ # Wait for at least one task to finish before continuing
345
+ if tasks:
346
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
347
+ tasks = list(pending) # Keep only pending tasks for the next iteration
348
+
349
+ # Collect results from completed tasks
350
+ for completed_task in done:
351
+ result = await completed_task
352
+ results.append(result)
353
+
354
+ # Ensure any remaining tasks are awaited and results collected
355
+ if tasks:
356
+ for task in tasks:
357
+ result = await task
358
+ results.append(result)
359
+
360
+ return {name: result for name, result in results}
361
+
362
+ def start(self) -> Dict[str, Any]:
363
+ """
364
+ Starts the asynchronous DAG execution.
365
+
366
+ Returns:
367
+ Dict[str, Any]: A dictionary mapping startable names to their results.
368
+ """
369
+ return asyncio.run(self._main_loop())
370
+
371
+
372
+ class ThreadPoolDag(Dag):
373
+ """
374
+ A `Dag` implementation using a thread pool.
375
+
376
+ The `ThreadPoolDag` class executes startables concurrently using a thread pool, limiting the number of threads
377
+ running concurrently to the specified maximum.
378
+
379
+ Attributes:
380
+ _max_workers (int): The maximum number of workers in the thread pool.
381
+
382
+ Methods:
383
+ _worker(sorter, startable, lock, results): Worker function to execute a startable in a thread.
384
+ start(): Starts the DAG execution using the thread pool and returns the results as a dictionary.
385
+ """
386
+
387
+ def __init__(self, name: str, max_workers: int = 6):
388
+ """
389
+ Initializes the thread pool DAG with the given name and maximum number of worker threads.
390
+
391
+ Args:
392
+ name (str): The name of the DAG.
393
+ max_workers (int, optional): The maximum number of worker threads. Defaults to 6.
394
+ """
395
+ super().__init__(name)
396
+ self._max_workers = max_workers
397
+
398
+ def _worker(self,
399
+ sorter: TopologicalSorter,
400
+ startable: Startable,
401
+ lock: threading.Lock,
402
+ results: Dict[str, Any]
403
+ ) -> None:
404
+ """
405
+ Worker function to execute a startable in a thread.
406
+
407
+ Args:
408
+ sorter (TopologicalSorter): The sorter to update as tasks are completed.
409
+ startable (Startable): The startable to execute.
410
+ lock (threading.Lock): Ensures thread safety when updating the sorter.
411
+ results (dict): A dictionary to store the results of executed startables.
412
+ """
413
+
414
+ result = startable.start()
415
+
416
+ with lock:
417
+ sorter.done(startable.name)
418
+ results[startable.name] = result
419
+
420
+ def start(self) -> Dict[str, Any]:
421
+ """
422
+ Starts the DAG execution using a thread pool.
423
+
424
+ Returns:
425
+ Dict[str, Any]: A dictionary mapping startable names to their results.
426
+ """
427
+ sorter = self.sorter
428
+ sorter.prepare()
429
+
430
+ lock = threading.Lock()
431
+ results = {}
432
+
433
+ with ThreadPoolExecutor(max_workers=self._max_workers) as executor:
434
+ futures: list[Future] = []
435
+ while sorter.is_active():
436
+ ready_names = []
437
+ with lock:
438
+ ready_names = sorter.get_ready()
439
+
440
+ for name in ready_names:
441
+ startable = self._startables[name]
442
+ future = executor.submit(self._worker, sorter, startable, lock, results)
443
+ futures.append(future)
444
+
445
+ for future in as_completed(futures):
446
+ future.result()
447
+
448
+ return results
449
+
450
+
451
+ class ThreadDag(Dag):
452
+ def __init__(self, name: str, max_workers: int = 6):
453
+ """
454
+ Initializes the threaded DAG with the given name and maximum number of threads.
455
+
456
+ Args:
457
+ name (str): The name of the DAG.
458
+ max_workers (int, optional): The maximum number of threads to run concurrently. Defaults to 6.
459
+ """
460
+ super().__init__(name)
461
+ self._max_workers = max_workers
462
+
463
+ def _worker(self,
464
+ sorter: TopologicalSorter,
465
+ startable: Startable,
466
+ results: Dict[str, Any],
467
+ lock: threading.Lock,
468
+ semaphore: threading.Semaphore
469
+ ) -> None:
470
+ """
471
+ Worker method to execute a task in a thread and update results.
472
+
473
+ This method runs each task in a separate thread and updates the results and topological sorter.
474
+
475
+ Args:
476
+ sorter (TopologicalSorter): The topological sorter for the DAG.
477
+ startable (Startable): The task to be executed.
478
+ results (Dict[str, Any]): A dictionary to store task results.
479
+ lock (threading.Lock): Lock to ensure thread-safe updates.
480
+ semaphore (threading.Semaphore): Semaphore to limit the number of concurrent threads.
481
+ """
482
+ with semaphore:
483
+ result = startable.start()
484
+
485
+ with lock:
486
+ results[startable.name] = result
487
+
488
+ with lock:
489
+ sorter.done(startable.name)
490
+
491
+ def start(self) -> Dict[str, Any]:
492
+ """
493
+ Starts the execution of tasks in the DAG using threads.
494
+
495
+ This method manages the execution of tasks using threads. It controls the maximum number of concurrent threads
496
+ using a semaphore and collects results in a shared dictionary.
497
+
498
+ Returns:
499
+ Dict[str, Any]: A dictionary mapping task names to their results.
500
+ """
501
+ sorter = self.sorter
502
+ sorter.prepare()
503
+
504
+ lock = threading.Lock()
505
+ semaphore = threading.Semaphore(self._max_workers) # Control the Number of Threads
506
+
507
+ results: dict[str, Any] = {}
508
+ workers: list[threading.Thread] = []
509
+
510
+ # Start the Loop
511
+ while sorter.is_active():
512
+ with lock:
513
+ ready_names = sorter.get_ready()
514
+
515
+ if ready_names:
516
+ for name in ready_names:
517
+ # Dispatch a worker only if there's room in the semaphore
518
+ startable = self._startables[name]
519
+ thread = threading.Thread(
520
+ target=self._worker,
521
+ args=(sorter, startable, results, lock, semaphore),
522
+ daemon=True
523
+ )
524
+ thread.start()
525
+ workers.append(thread)
526
+
527
+ # Wait for all threads to finish
528
+ for thread in workers:
529
+ thread.join()
530
+
531
+ return results
532
+
533
+
534
+ class MultiprocessDag(Dag):
535
+ """
536
+ A Directed Acyclic Graph (DAG) that executes tasks concurrently using multiple worker processes.
537
+
538
+ This class utilizes multiple processes to execute tasks in parallel, allowing for parallel computation across
539
+ multiple CPU cores. Each task is processed by one of the available worker processes, while a marker thread
540
+ tracks completed tasks and updates the DAG sorter to ensure task dependencies are respected. Tasks are
541
+ submitted to a queue, executed, and results are stored in a shared namespace.
542
+
543
+ Attributes:
544
+ _max_processes (int): The maximum number of worker processes to use for concurrent execution. Default is 4.
545
+ """
546
+
547
+ def __init__(self, name: str, max_processes=4):
548
+ """
549
+ Initializes the multiprocess DAG with the given name and maximum number of worker processes.
550
+
551
+ This class executes tasks concurrently using multiple processes, allowing parallel execution across different
552
+ CPUs.
553
+
554
+ Args:
555
+ name (str): The name of the DAG.
556
+ max_processes (int, optional): The maximum number of worker processes. Defaults to 4.
557
+ """
558
+ super().__init__(name)
559
+ self._max_processes = max_processes
560
+
561
+ @staticmethod
562
+ def _worker(ns, lock, startable_queue, done_queue) -> None:
563
+ """
564
+ Worker method that processes tasks from the startable_queue and stores results in the shared namespace.
565
+
566
+ Each worker takes tasks from the queue, processes them, and stores the result in a thread-safe dictionary.
567
+ Once the task is completed, it is put into the done_queue to be marked as done for sorter.
568
+
569
+ Args:
570
+ ns (multiprocessing.Namespace): Shared namespace to store task results.
571
+ lock (multiprocessing.Lock): A lock to synchronize access to the shared results.
572
+ startable_queue (multiprocessing.Queue): Queue of startable tasks to be processed.
573
+ done_queue (multiprocessing.Queue): Queue to track completed tasks.
574
+ """
575
+ while True:
576
+ if not startable_queue.empty():
577
+ startable = startable_queue.get()
578
+ result = startable.start()
579
+
580
+ with lock:
581
+ ns.results[startable.name] = result
582
+
583
+ done_queue.put(startable)
584
+ startable_queue.task_done()
585
+
586
+ @staticmethod
587
+ def _marker(sorter, lock, done_queue, stop_event) -> None:
588
+ """
589
+ Marker thread that tracks task completion and updates the sorter.
590
+
591
+ The marker thread continually monitors the done_queue for completed tasks and updates the DAG sorter
592
+ to mark tasks as done, ensuring the DAG is correctly processed.
593
+
594
+ Args:
595
+ sorter (TopologicalSorter): The sorter managing task dependencies.
596
+ lock (multiprocessing.Lock): A lock to synchronize access to the sorter.
597
+ done_queue (multiprocessing.Queue): Queue tracking tasks that have been completed.
598
+ stop_event (multiprocessing.Event): Event to signal when the process should stop.
599
+ """
600
+ while not (stop_event.is_set() and done_queue.empty() and not sorter.is_active()):
601
+ if not done_queue.empty():
602
+ startable = done_queue.get()
603
+ with lock:
604
+ sorter.done(startable.name)
605
+ done_queue.task_done()
606
+
607
+ def start(self) -> Dict[str, Any]:
608
+ """
609
+ Starts the execution of tasks in the DAG using multiple processes and a marker thread.
610
+
611
+ The method prepares the DAG, starts worker processes to handle tasks in parallel, and uses a marker thread
612
+ to track task completion. It ensures that all tasks are processed and results are collected.
613
+
614
+ Args:
615
+ name (str): The name of the DAG.
616
+
617
+ Returns:
618
+ Dict[str, Any]: A dictionary mapping task names to their results.
619
+ """
620
+ sorter = self.sorter
621
+ sorter.prepare()
622
+
623
+ with multiprocessing.Manager() as manager:
624
+ # Use Namespace to share results
625
+ ns = manager.Namespace()
626
+ ns.results = manager.dict() # Dictionary of Results
627
+
628
+ # Mutex
629
+ lock = manager.Lock()
630
+
631
+ # Queues
632
+ startable_queue = manager.Queue()
633
+ done_queue = manager.Queue()
634
+
635
+ # Events
636
+ stop_event = manager.Event()
637
+
638
+ # Start the Worker Processes
639
+ workers: list[multiprocessing.Process] = []
640
+ for _ in range(self._max_processes):
641
+ process = multiprocessing.Process(
642
+ target=self._worker,
643
+ args=(ns, lock, startable_queue, done_queue),
644
+ daemon=True
645
+ )
646
+ process.start()
647
+ workers.append(process)
648
+
649
+ # Start the Marker Thread
650
+ marker_thread = threading.Thread(
651
+ target=self._marker,
652
+ args=(sorter, lock, done_queue, stop_event),
653
+ daemon=True
654
+ )
655
+ marker_thread.start()
656
+
657
+ # Loop until all startables are processed and completed
658
+ while sorter.is_active():
659
+ with lock:
660
+ ready_names = sorter.get_ready()
661
+
662
+ if ready_names:
663
+ for name in ready_names:
664
+ startable = self._startables[name]
665
+ startable_queue.put(startable)
666
+
667
+ # Wait until all startables are processed
668
+ startable_queue.join()
669
+
670
+ # Wait until all startables are marked
671
+ done_queue.join()
672
+
673
+ # Set the Stop Event to signal the Processes and Thread
674
+ stop_event.set()
675
+
676
+ # Wait until marker is done
677
+ marker_thread.join()
678
+
679
+ # Terminate all worker processes
680
+ for process in workers:
681
+ process.terminate()
682
+
683
+ return dict(ns.results)
684
+
685
+
686
+ class ProcessPoolDag(Dag):
687
+ """
688
+ A Directed Acyclic Graph (DAG) that executes tasks concurrently using a process pool.
689
+
690
+ This class uses a `ProcessPoolExecutor` to execute tasks concurrently in a pool of worker processes. Tasks
691
+ are submitted to the pool and executed in parallel. The results are gathered as they complete, and the task
692
+ dependencies are handled by the DAG sorter to ensure tasks execute in the correct order.
693
+
694
+ Attributes:
695
+ _max_processes (int): The maximum number of processes to use in the pool for concurrent execution. Default is 4.
696
+ """
697
+
698
+ def __init__(self, name: str, max_processes: int = 4):
699
+ """
700
+ Initializes the process pool DAG with the given name and maximum number of worker processes in the pool.
701
+
702
+ This class uses a process pool to execute tasks concurrently. Each task is submitted to the pool, and the results
703
+ are tracked as they are completed.
704
+
705
+ Args:
706
+ name (str): The name of the DAG.
707
+ max_processes (int, optional): The maximum number of processes to run concurrently in the process pool.
708
+ Defaults to 4.
709
+ """
710
+ super().__init__(name)
711
+ self._max_processes = max_processes
712
+
713
+ def start(self) -> Dict[str, Any]:
714
+ """
715
+ Starts the execution of tasks in the DAG using a process pool.
716
+
717
+ The method submits tasks to the process pool, waits for their completion, and collects results. It ensures that
718
+ tasks are executed concurrently and the results are properly handled and sorted.
719
+
720
+ Args:
721
+ name (str): The name of the DAG.
722
+
723
+ Returns:
724
+ Dict[str, Any]: A dictionary mapping task names to their results.
725
+ """
726
+ sorter = self.sorter
727
+ sorter.prepare()
728
+
729
+ results = {}
730
+ with ProcessPoolExecutor(max_workers=self._max_processes) as pool:
731
+ futures: dict[Future, Startable] = {}
732
+
733
+ while sorter.is_active():
734
+ ready_names = sorter.get_ready()
735
+ for name in ready_names:
736
+ startable = self._startables[name]
737
+ futures[pool.submit(startable.start)] = startable
738
+
739
+ # Check completed futures without blocking others
740
+ completed_futures = [f for f in futures if f.done()] # Polling
741
+ for future in completed_futures:
742
+ startable = futures[future]
743
+ result = future.result()
744
+ results[startable.name] = result
745
+ sorter.done(startable.name)
746
+ del futures[future]
747
+
748
+ return results