apache-hamilton 1.90.0.dev0__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 (151) hide show
  1. apache_hamilton-1.90.0.dev0.dist-info/METADATA +407 -0
  2. apache_hamilton-1.90.0.dev0.dist-info/RECORD +151 -0
  3. apache_hamilton-1.90.0.dev0.dist-info/WHEEL +4 -0
  4. apache_hamilton-1.90.0.dev0.dist-info/entry_points.txt +9 -0
  5. apache_hamilton-1.90.0.dev0.dist-info/licenses/DISCLAIMER +10 -0
  6. apache_hamilton-1.90.0.dev0.dist-info/licenses/LICENSE +228 -0
  7. apache_hamilton-1.90.0.dev0.dist-info/licenses/NOTICE +5 -0
  8. hamilton/__init__.py +24 -0
  9. hamilton/ad_hoc_utils.py +132 -0
  10. hamilton/async_driver.py +465 -0
  11. hamilton/base.py +466 -0
  12. hamilton/caching/__init__.py +16 -0
  13. hamilton/caching/adapter.py +1475 -0
  14. hamilton/caching/cache_key.py +70 -0
  15. hamilton/caching/fingerprinting.py +287 -0
  16. hamilton/caching/stores/__init__.py +16 -0
  17. hamilton/caching/stores/base.py +242 -0
  18. hamilton/caching/stores/file.py +140 -0
  19. hamilton/caching/stores/memory.py +297 -0
  20. hamilton/caching/stores/sqlite.py +282 -0
  21. hamilton/caching/stores/utils.py +40 -0
  22. hamilton/cli/__init__.py +16 -0
  23. hamilton/cli/__main__.py +328 -0
  24. hamilton/cli/commands.py +126 -0
  25. hamilton/cli/logic.py +338 -0
  26. hamilton/common/__init__.py +76 -0
  27. hamilton/contrib/__init__.py +41 -0
  28. hamilton/data_quality/__init__.py +16 -0
  29. hamilton/data_quality/base.py +198 -0
  30. hamilton/data_quality/default_validators.py +560 -0
  31. hamilton/data_quality/pandera_validators.py +121 -0
  32. hamilton/dataflows/__init__.py +726 -0
  33. hamilton/dataflows/template/README.md +25 -0
  34. hamilton/dataflows/template/__init__.py +54 -0
  35. hamilton/dataflows/template/author.md +28 -0
  36. hamilton/dataflows/template/requirements.txt +0 -0
  37. hamilton/dataflows/template/tags.json +7 -0
  38. hamilton/dataflows/template/valid_configs.jsonl +1 -0
  39. hamilton/dev_utils/__init__.py +16 -0
  40. hamilton/dev_utils/deprecation.py +204 -0
  41. hamilton/driver.py +2112 -0
  42. hamilton/execution/__init__.py +16 -0
  43. hamilton/execution/debugging_utils.py +56 -0
  44. hamilton/execution/executors.py +502 -0
  45. hamilton/execution/graph_functions.py +421 -0
  46. hamilton/execution/grouping.py +430 -0
  47. hamilton/execution/state.py +539 -0
  48. hamilton/experimental/__init__.py +27 -0
  49. hamilton/experimental/databackend.py +61 -0
  50. hamilton/experimental/decorators/__init__.py +16 -0
  51. hamilton/experimental/decorators/parameterize_frame.py +233 -0
  52. hamilton/experimental/h_async.py +29 -0
  53. hamilton/experimental/h_cache.py +413 -0
  54. hamilton/experimental/h_dask.py +28 -0
  55. hamilton/experimental/h_databackends.py +174 -0
  56. hamilton/experimental/h_ray.py +28 -0
  57. hamilton/experimental/h_spark.py +32 -0
  58. hamilton/function_modifiers/README +40 -0
  59. hamilton/function_modifiers/__init__.py +121 -0
  60. hamilton/function_modifiers/adapters.py +900 -0
  61. hamilton/function_modifiers/base.py +859 -0
  62. hamilton/function_modifiers/configuration.py +310 -0
  63. hamilton/function_modifiers/delayed.py +202 -0
  64. hamilton/function_modifiers/dependencies.py +246 -0
  65. hamilton/function_modifiers/expanders.py +1230 -0
  66. hamilton/function_modifiers/macros.py +1634 -0
  67. hamilton/function_modifiers/metadata.py +434 -0
  68. hamilton/function_modifiers/recursive.py +908 -0
  69. hamilton/function_modifiers/validation.py +289 -0
  70. hamilton/function_modifiers_base.py +31 -0
  71. hamilton/graph.py +1153 -0
  72. hamilton/graph_types.py +264 -0
  73. hamilton/graph_utils.py +41 -0
  74. hamilton/htypes.py +450 -0
  75. hamilton/io/__init__.py +32 -0
  76. hamilton/io/data_adapters.py +216 -0
  77. hamilton/io/default_data_loaders.py +224 -0
  78. hamilton/io/materialization.py +500 -0
  79. hamilton/io/utils.py +158 -0
  80. hamilton/lifecycle/__init__.py +67 -0
  81. hamilton/lifecycle/api.py +833 -0
  82. hamilton/lifecycle/base.py +1130 -0
  83. hamilton/lifecycle/default.py +802 -0
  84. hamilton/log_setup.py +47 -0
  85. hamilton/models.py +92 -0
  86. hamilton/node.py +449 -0
  87. hamilton/plugins/README.md +48 -0
  88. hamilton/plugins/__init__.py +16 -0
  89. hamilton/plugins/dask_extensions.py +47 -0
  90. hamilton/plugins/dlt_extensions.py +161 -0
  91. hamilton/plugins/geopandas_extensions.py +49 -0
  92. hamilton/plugins/h_dask.py +331 -0
  93. hamilton/plugins/h_ddog.py +522 -0
  94. hamilton/plugins/h_diskcache.py +163 -0
  95. hamilton/plugins/h_experiments/__init__.py +22 -0
  96. hamilton/plugins/h_experiments/__main__.py +62 -0
  97. hamilton/plugins/h_experiments/cache.py +39 -0
  98. hamilton/plugins/h_experiments/data_model.py +68 -0
  99. hamilton/plugins/h_experiments/hook.py +219 -0
  100. hamilton/plugins/h_experiments/server.py +375 -0
  101. hamilton/plugins/h_kedro.py +152 -0
  102. hamilton/plugins/h_logging.py +454 -0
  103. hamilton/plugins/h_mcp/__init__.py +28 -0
  104. hamilton/plugins/h_mcp/__main__.py +33 -0
  105. hamilton/plugins/h_mcp/_helpers.py +129 -0
  106. hamilton/plugins/h_mcp/_templates.py +417 -0
  107. hamilton/plugins/h_mcp/server.py +328 -0
  108. hamilton/plugins/h_mlflow.py +335 -0
  109. hamilton/plugins/h_narwhals.py +134 -0
  110. hamilton/plugins/h_openlineage.py +400 -0
  111. hamilton/plugins/h_opentelemetry.py +167 -0
  112. hamilton/plugins/h_pandas.py +257 -0
  113. hamilton/plugins/h_pandera.py +117 -0
  114. hamilton/plugins/h_polars.py +304 -0
  115. hamilton/plugins/h_polars_lazyframe.py +282 -0
  116. hamilton/plugins/h_pyarrow.py +56 -0
  117. hamilton/plugins/h_pydantic.py +127 -0
  118. hamilton/plugins/h_ray.py +242 -0
  119. hamilton/plugins/h_rich.py +142 -0
  120. hamilton/plugins/h_schema.py +493 -0
  121. hamilton/plugins/h_slack.py +100 -0
  122. hamilton/plugins/h_spark.py +1380 -0
  123. hamilton/plugins/h_threadpool.py +125 -0
  124. hamilton/plugins/h_tqdm.py +122 -0
  125. hamilton/plugins/h_vaex.py +129 -0
  126. hamilton/plugins/huggingface_extensions.py +236 -0
  127. hamilton/plugins/ibis_extensions.py +93 -0
  128. hamilton/plugins/jupyter_magic.py +622 -0
  129. hamilton/plugins/kedro_extensions.py +117 -0
  130. hamilton/plugins/lightgbm_extensions.py +99 -0
  131. hamilton/plugins/matplotlib_extensions.py +108 -0
  132. hamilton/plugins/mlflow_extensions.py +216 -0
  133. hamilton/plugins/numpy_extensions.py +105 -0
  134. hamilton/plugins/pandas_extensions.py +1763 -0
  135. hamilton/plugins/plotly_extensions.py +150 -0
  136. hamilton/plugins/polars_extensions.py +71 -0
  137. hamilton/plugins/polars_implementations.py +25 -0
  138. hamilton/plugins/polars_lazyframe_extensions.py +302 -0
  139. hamilton/plugins/polars_post_1_0_0_extensions.py +877 -0
  140. hamilton/plugins/polars_pre_1_0_0_extension.py +836 -0
  141. hamilton/plugins/pydantic_extensions.py +98 -0
  142. hamilton/plugins/pyspark_pandas_extensions.py +47 -0
  143. hamilton/plugins/sklearn_plot_extensions.py +129 -0
  144. hamilton/plugins/spark_extensions.py +105 -0
  145. hamilton/plugins/vaex_extensions.py +51 -0
  146. hamilton/plugins/xgboost_extensions.py +91 -0
  147. hamilton/plugins/yaml_extensions.py +89 -0
  148. hamilton/registry.py +254 -0
  149. hamilton/settings.py +18 -0
  150. hamilton/telemetry.py +50 -0
  151. hamilton/version.py +18 -0
@@ -0,0 +1,16 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
@@ -0,0 +1,56 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+
19
+ from hamilton.execution.grouping import NodeGroup, NodeGroupPurpose, TaskSpec
20
+
21
+ """A set of utilities for debugging/printing out data"""
22
+ group_purpose_icons = {
23
+ NodeGroupPurpose.EXPAND_UNORDERED: "⫳",
24
+ NodeGroupPurpose.GATHER: "⋃",
25
+ NodeGroupPurpose.EXECUTE_BLOCK: "᠅",
26
+ NodeGroupPurpose.EXECUTE_SINGLE: "•",
27
+ }
28
+
29
+
30
+ def print_node_groups(node_groups: list[NodeGroup]):
31
+ """Prints out the node groups in a clean, tree-like format.
32
+
33
+ :param node_groups:
34
+ :return:
35
+ """
36
+ for group in node_groups:
37
+ node_icon = group_purpose_icons[group.purpose]
38
+ print(f"{node_icon} {group.base_id}")
39
+ for node_ in group.nodes:
40
+ print(f" • {node_.name} [ƒ({','.join(map(lambda n: n.name, node_.dependencies))})]")
41
+
42
+
43
+ def print_tasks(tasks: list[TaskSpec]):
44
+ """Prints out the node groups in a clean, tree-like format.
45
+
46
+ :param tasks:
47
+ :return:
48
+ """
49
+ print()
50
+ for task in tasks:
51
+ node_icon = group_purpose_icons[task.purpose]
52
+ print(f"{node_icon} {task.base_id} [ƒ({', '.join(task.base_dependencies)})]")
53
+ for node_ in task.nodes:
54
+ print(
55
+ f" • {node_.name}"
56
+ ) # ƒ({', '.join(map(lambda n: n.name, node_.dependencies))})")
@@ -0,0 +1,502 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ import abc
19
+ import dataclasses
20
+ import functools
21
+ import logging
22
+ import sys
23
+
24
+ if sys.platform in ("emscripten", "wasi"):
25
+ ProcessPoolExecutor = None
26
+ else:
27
+ from concurrent.futures.process import ProcessPoolExecutor
28
+
29
+ from collections.abc import Callable
30
+ from concurrent.futures import Executor, Future, ThreadPoolExecutor
31
+ from typing import Any, Protocol
32
+
33
+ from hamilton import node
34
+ from hamilton.execution.graph_functions import execute_subdag
35
+ from hamilton.execution.grouping import NodeGroupPurpose, TaskImplementation
36
+ from hamilton.execution.state import ExecutionState, GraphState, TaskState
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class TaskFuture(Protocol):
42
+ """Simple representation of a future. TODO -- add cancel().
43
+ This a clean wrapper over a python future, and we may end up just using that at some point."""
44
+
45
+ def get_state(self) -> TaskState:
46
+ """Returns the state of the task."""
47
+ ...
48
+
49
+ def get_result(self) -> Any:
50
+ """Returns the result of the task."""
51
+ ...
52
+
53
+
54
+ class TaskExecutor(abc.ABC):
55
+ """Abstract class for a task executor. All this does is submit a task and return a future.
56
+ It also tells us if it can do that"""
57
+
58
+ @abc.abstractmethod
59
+ def init(self):
60
+ """Initializes the task executor, provisioning any necessary resources."""
61
+ pass
62
+
63
+ @abc.abstractmethod
64
+ def finalize(self):
65
+ """Tears down the task executor, freeing up any provisioned resources.
66
+ Will be called in a finally block."""
67
+ pass
68
+
69
+ @abc.abstractmethod
70
+ def submit_task(self, task: TaskImplementation) -> TaskFuture:
71
+ """Submits a task to the executor. Returns a task ID that can be used to query the status.
72
+ Effectively a future.
73
+
74
+ :param task: Task implementation (bound with arguments) to submit
75
+ :return: The future representing the task's computation.
76
+ """
77
+ pass
78
+
79
+ @abc.abstractmethod
80
+ def can_submit_task(self) -> bool:
81
+ """Returns whether or not we can submit a task to the executor.
82
+ For instance, if the maximum parallelism is reached, we may not be able to submit a task.
83
+
84
+ TODO -- consider if this should be a "parallelism" value instead of a boolean, forcing
85
+ the ExecutionState to store the state prior to executing a task.
86
+
87
+ :return: whether or not we can submit a task.
88
+ """
89
+ pass
90
+
91
+
92
+ def new_callable(*args, _callable=None, **kwargs):
93
+ return list(_callable(*args, **kwargs))
94
+
95
+
96
+ def _modify_callable(node_source: node.NodeType, callabl: Callable):
97
+ """This is a bit of a shortcut -- we modify the callable here as
98
+ we want to allow `Parallelizable[]` nodes to return a generator
99
+
100
+ :param node_source:
101
+ :param callabl:
102
+ :return:
103
+ """
104
+ if node_source == node.NodeType.EXPAND:
105
+ return functools.partial(new_callable, _callable=callabl)
106
+ return callabl
107
+
108
+
109
+ def base_execute_task(task: TaskImplementation) -> dict[str, Any]:
110
+ """This is a utility function to execute a base task. In an ideal world this would be recursive,
111
+ (as in we can use the same task execution/management system as we would otherwise)
112
+ but for now we just call out to good old DFS. Note that this only returns the result that
113
+ a task is required to output, and does not return anything else. It also returns
114
+ any overrides.
115
+
116
+ We should probably have a simple way of doing this for single-node tasks, as they're
117
+ going to be common.
118
+
119
+ :param task: task to execute.
120
+ :return: a diciontary of the results of all the nodes in that task's nodes to compute.
121
+ """
122
+ # We do this as an edge case to force the callable to return a list if it is an expand,
123
+ # and would normally return a generator. That said, we will likely remove this in the future --
124
+ # its an implementation detail, and a true queuing system between nodes/controller would mean
125
+ # we wouldn't need to do this, and instead could just use the generator aspect.
126
+ # Furthermore, in most cases the user wouldn't be calling an expand on a "remote" node,
127
+ # but it is a supported use-case.
128
+ for node_ in task.nodes:
129
+ if not getattr(node_, "callable_modified", False):
130
+ node_._callable = _modify_callable(node_.node_role, node_.callable)
131
+ node_.callable_modified = True
132
+ if task.adapter.does_hook("pre_task_execute", is_async=False):
133
+ task.adapter.call_all_lifecycle_hooks_sync(
134
+ "pre_task_execute",
135
+ run_id=task.run_id,
136
+ task_id=task.task_id,
137
+ nodes=task.nodes,
138
+ inputs=task.dynamic_inputs,
139
+ overrides=task.overrides,
140
+ spawning_task_id=task.spawning_task_id,
141
+ purpose=task.purpose,
142
+ )
143
+ error = None
144
+ success = True
145
+ results = None
146
+ try:
147
+ results = execute_subdag(
148
+ nodes=task.nodes,
149
+ inputs=task.dynamic_inputs,
150
+ adapter=task.adapter,
151
+ overrides={**task.dynamic_inputs, **task.overrides},
152
+ run_id=task.run_id,
153
+ task_id=task.task_id,
154
+ )
155
+ except Exception as e:
156
+ logger.exception(task.task_id)
157
+ error = e
158
+ success = False
159
+ logger.exception(
160
+ f"Exception executing task {task.task_id}, with nodes: {[item.name for item in task.nodes]}"
161
+ )
162
+ raise e
163
+ finally:
164
+ if task.adapter.does_hook("post_task_execute", is_async=False):
165
+ task.adapter.call_all_lifecycle_hooks_sync(
166
+ "post_task_execute",
167
+ run_id=task.run_id,
168
+ task_id=task.task_id,
169
+ nodes=task.nodes,
170
+ results=results,
171
+ success=success,
172
+ error=error,
173
+ spawning_task_id=task.spawning_task_id,
174
+ purpose=task.purpose,
175
+ )
176
+ # This selection is for GC
177
+ # We also need to get the override values
178
+ # This way if its overridden we can ensure it gets passed to the right one
179
+ final_retval = {
180
+ key: value
181
+ for key, value in results.items()
182
+ if key in task.outputs_to_compute or key in task.overrides
183
+ }
184
+ return final_retval
185
+
186
+
187
+ @dataclasses.dataclass
188
+ class TaskFutureWrappingFunction(TaskFuture):
189
+ """Wraps a python function call in a TaskFuture."""
190
+
191
+ def __init__(self, function: Callable[[], Any]):
192
+ self.function = function
193
+ self._results = None
194
+ self._done = False
195
+ self._exception = None
196
+
197
+ def get_state(self):
198
+ if self._exception is not None:
199
+ return TaskState.FAILED
200
+ if not self._done:
201
+ try:
202
+ self._results = self.function()
203
+ except Exception as e:
204
+ logger.exception("Task failed")
205
+ self._exception = e
206
+ return TaskState.FAILED
207
+ finally:
208
+ self._done = True
209
+ return TaskState.SUCCESSFUL
210
+
211
+ def get_result(self):
212
+ if self._exception is not None:
213
+ raise self._exception
214
+ if not self._done:
215
+ self._results = self.function()
216
+ self._done = True
217
+ return self._results
218
+
219
+
220
+ class SynchronousLocalTaskExecutor(TaskExecutor):
221
+ """Basic synchronous/local task executor that runs tasks
222
+ in the same process, at submit time."""
223
+
224
+ def submit_task(self, task: TaskImplementation) -> TaskFuture:
225
+ """Submitting a task is literally just running it.
226
+
227
+ :param task: Task to submit
228
+ :return: Future associated with this task
229
+ """
230
+ return TaskFutureWrappingFunction(functools.partial(base_execute_task, task))
231
+
232
+ def can_submit_task(self) -> bool:
233
+ """We can always submit a task as the task submission is blocking!
234
+
235
+ :return: True
236
+ """
237
+ return True
238
+
239
+ def init(self):
240
+ pass
241
+
242
+ def finalize(self):
243
+ pass
244
+
245
+
246
+ @dataclasses.dataclass
247
+ class TaskFutureWrappingPythonFuture(TaskFuture):
248
+ """Wraps a python future in a TaskFuture"""
249
+
250
+ def __init__(self, future: Future):
251
+ self.future = future
252
+
253
+ def get_state(self):
254
+ """Gets the state. This is non-blocking."""
255
+
256
+ if self.future.done():
257
+ try:
258
+ self.future.result()
259
+ except Exception:
260
+ logger.exception("Task failed")
261
+ return TaskState.FAILED
262
+ return TaskState.SUCCESSFUL
263
+ else:
264
+ return TaskState.RUNNING
265
+
266
+ def get_result(self):
267
+ """Gets the result. This is non-blocking.
268
+
269
+ :return: None if there is no result, else the result
270
+ """
271
+ if not self.future.done():
272
+ return None
273
+ out = self.future.result()
274
+ return out
275
+
276
+
277
+ class PoolExecutor(TaskExecutor, abc.ABC):
278
+ """Base class for a pool-based executor (threadpool executor/multiprocessing executor).
279
+ Handles common logic, stores active future, and manages max tasks.
280
+ """
281
+
282
+ def __init__(self, max_tasks: int):
283
+ self.active_futures = []
284
+ self.initialized = False
285
+ self.pool = None
286
+ self.max_tasks = max_tasks # TODO -- allow infinite/no max.
287
+
288
+ def _prune_active_futures(self):
289
+ self.active_futures = [f for f in self.active_futures if not f.done()]
290
+
291
+ @abc.abstractmethod
292
+ def create_pool(self) -> Executor:
293
+ """Creates a pool to submit tasks to.
294
+
295
+ :return: The executor to handle all the tasks in the pool
296
+ """
297
+ pass
298
+
299
+ def init(self):
300
+ """Creates/initializes pool"""
301
+ if not self.initialized:
302
+ self.pool = self.create_pool()
303
+ self.initialized = True
304
+ else:
305
+ raise RuntimeError("Cannot initialize an already initialized executor")
306
+
307
+ def finalize(self):
308
+ """Finalizes pool, freeing up resources"""
309
+ if self.initialized:
310
+ self.pool.shutdown()
311
+ self.initialized = False
312
+ else:
313
+ raise RuntimeError("Cannot finalize an uninitialized executor")
314
+
315
+ def submit_task(self, task: TaskImplementation) -> TaskFuture:
316
+ """Submitting a task is literally just running it.
317
+
318
+ :param task: Task to submit
319
+ :return: The future associated with the task
320
+ """
321
+ # First submit it
322
+ # Then we need to wrap it in a future
323
+ future = self.pool.submit(base_execute_task, task)
324
+ self.active_futures.append(future)
325
+ return TaskFutureWrappingPythonFuture(future)
326
+
327
+ def can_submit_task(self) -> bool:
328
+ """Tells if the pool is full or not.
329
+
330
+ :return:
331
+ """
332
+
333
+ self._prune_active_futures()
334
+ return len(self.active_futures) < self.max_tasks
335
+
336
+
337
+ class MultiThreadingExecutor(PoolExecutor):
338
+ """Basic synchronous/local task executor that runs tasks
339
+ in the same process, at submit time."""
340
+
341
+ def create_pool(self) -> ThreadPoolExecutor:
342
+ return ThreadPoolExecutor(max_workers=self.max_tasks)
343
+
344
+
345
+ class MultiProcessingExecutor(PoolExecutor):
346
+ """Basic synchronous/local task executor that runs tasks
347
+ in the same process, at submit time. Note that this is
348
+ not yet augmented to handle the right serialization,
349
+ so use at your own risk. We will be fixing shortly,
350
+ but the dask/ray parallelism and the multithreading
351
+ parallelism executors serialize correctly."""
352
+
353
+ def create_pool(self) -> ProcessPoolExecutor:
354
+ return ProcessPoolExecutor(max_workers=self.max_tasks)
355
+
356
+
357
+ class ExecutionManager(abc.ABC):
358
+ """Manages execution per task. This enables you to have different executors for different
359
+ tasks/task types. Note that, currently, it just uses the task information, but we could
360
+ theoretically add metadata in a task as well.
361
+ """
362
+
363
+ def __init__(self, executors: list[TaskExecutor]):
364
+ """Initializes the execution manager. Note this does not start it up/claim resources --
365
+ you need to call init() to do that.
366
+
367
+ :param executors:
368
+ """
369
+ self.executors = executors
370
+
371
+ def init(self):
372
+ """Initializes each of the executors."""
373
+ for executor in self.executors:
374
+ executor.init()
375
+
376
+ def finalize(self):
377
+ """Finalizes each of the executors."""
378
+ for executor in self.executors:
379
+ executor.finalize()
380
+
381
+ @abc.abstractmethod
382
+ def get_executor_for_task(self, task: TaskImplementation) -> TaskExecutor:
383
+ """Selects the executor for the task. This enables us to set the appropriate executor
384
+ for specific tasks (so that we can run some locally, some remotely, etc...).
385
+
386
+ Note that this is the power-user case -- in all likelihood, people will use the default
387
+ ExecutionManager.
388
+
389
+ :param task: Task to choose execution manager for
390
+ :return: The executor to use for this task
391
+ """
392
+ pass
393
+
394
+
395
+ class DefaultExecutionManager(ExecutionManager):
396
+ def __init__(self, local_executor=None, remote_executor=None):
397
+ """Instantiates a BasicExecutionManager with a local/remote executor.
398
+ These enable us to run certain tasks locally (simple transformations, generating sets of files),
399
+ and certain tasks remotely (processing files in large datasets, etc...)
400
+
401
+ :param local_executor: Executor to use for running tasks locally
402
+ :param remote_executor: Executor to use for running tasks remotely
403
+ """
404
+ if local_executor is None:
405
+ local_executor = SynchronousLocalTaskExecutor()
406
+ if remote_executor is None:
407
+ remote_executor = MultiProcessingExecutor(max_tasks=5)
408
+ super().__init__([local_executor, remote_executor])
409
+ self.local_executor = local_executor
410
+ self.remote_executor = remote_executor
411
+
412
+ def get_executor_for_task(self, task: TaskImplementation) -> TaskExecutor:
413
+ """Simple implementation that returns the local executor for single task executions,
414
+
415
+ :param task: Task to get executor for
416
+ :return: A local task if this is a "single-node" task, a remote task otherwise
417
+ """
418
+
419
+ if task.purpose == NodeGroupPurpose.EXECUTE_BLOCK:
420
+ return self.remote_executor
421
+ return self.local_executor
422
+
423
+
424
+ def run_graph_to_completion(
425
+ execution_state: ExecutionState,
426
+ execution_manager: ExecutionManager,
427
+ ):
428
+ """Blocking call to run the graph until it is complete. Note that this employs a while loop.
429
+ With the way we handle futures, we should be able to have this event-driven, allowing us
430
+ to only query when the state is updated, and use that to trigger a new update.
431
+
432
+ For now, the while loop is fine.
433
+
434
+ :return: Nothing, the execution state/result cache can give us the data
435
+ """
436
+ task_futures = {}
437
+ execution_manager.init()
438
+ try:
439
+ while not GraphState.is_terminal(execution_state.get_graph_state()):
440
+ # Get the next task from the queue
441
+ next_task = execution_state.release_next_task()
442
+ if next_task is not None:
443
+ task_executor = execution_manager.get_executor_for_task(next_task)
444
+ if task_executor.can_submit_task():
445
+ if next_task.adapter.does_hook("pre_task_submission", is_async=False):
446
+ next_task.adapter.call_all_lifecycle_hooks_sync(
447
+ "pre_task_submission",
448
+ run_id=next_task.run_id,
449
+ task_id=next_task.task_id,
450
+ nodes=next_task.nodes,
451
+ inputs=next_task.dynamic_inputs,
452
+ overrides=next_task.overrides,
453
+ spawning_task_id=next_task.spawning_task_id,
454
+ purpose=next_task.purpose,
455
+ )
456
+ try:
457
+ submitted = task_executor.submit_task(next_task)
458
+ except Exception as e:
459
+ logger.exception(
460
+ f"Exception submitting task {next_task.task_id}, with nodes: "
461
+ f"{[item.name for item in next_task.nodes]}"
462
+ )
463
+ raise e
464
+ task_futures[next_task] = submitted
465
+ else:
466
+ # Whoops, back on the queue. We should probably wait a bit here, but for
467
+ # now we're going to keep burning through
468
+ execution_state.reject_task(task_to_reject=next_task)
469
+
470
+ # Update all the tasks in flight (copy so we can modify)
471
+ for task, task_future in task_futures.copy().items():
472
+ result, error = None, None
473
+ state = task_future.get_state()
474
+ try:
475
+ result = task_future.get_result()
476
+ except Exception as e:
477
+ logger.exception(
478
+ f"Exception resolving task {task.task_id}, with nodes: "
479
+ f"{[item.name for item in task.nodes]}"
480
+ )
481
+ error = e
482
+ finally:
483
+ execution_state.update_task_state(task.task_id, state, result)
484
+ if TaskState.is_terminal(state):
485
+ if task.adapter.does_hook("post_task_return", is_async=False):
486
+ task.adapter.call_all_lifecycle_hooks_sync(
487
+ "post_task_return",
488
+ run_id=task.run_id,
489
+ task_id=task.task_id,
490
+ nodes=task.nodes,
491
+ success=state == TaskState.SUCCESSFUL,
492
+ error=error,
493
+ result=result,
494
+ spawning_task_id=task.spawning_task_id,
495
+ purpose=task.purpose,
496
+ )
497
+ del task_futures[task]
498
+ if error:
499
+ raise error
500
+ logger.info(f"Graph is done, graph state is {execution_state.get_graph_state()}")
501
+ finally:
502
+ execution_manager.finalize()