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,539 @@
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 collections
20
+ import enum
21
+ import logging
22
+ from typing import Any
23
+
24
+ from hamilton.execution.grouping import NodeGroupPurpose, TaskImplementation, TaskSpec
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TaskState(enum.Enum):
30
+ QUEUED = "queued"
31
+ UNINITIALIZED = "uninitialized"
32
+ INITIALIZED = "initialized"
33
+ RUNNING = "running"
34
+ SUCCESSFUL = "successful"
35
+ FAILED = "failed"
36
+
37
+ @staticmethod
38
+ def is_terminal(task_state: "TaskState") -> bool:
39
+ return task_state in [TaskState.SUCCESSFUL, TaskState.FAILED]
40
+
41
+
42
+ # TODO -- determine a better set of states for the graph
43
+ GraphState = TaskState
44
+
45
+
46
+ class ResultCache(abc.ABC):
47
+ """Cache of intermediate results. Will likely want to add pruning to this..."""
48
+
49
+ @abc.abstractmethod
50
+ def write(
51
+ self,
52
+ results: dict[str, Any],
53
+ group_id: str | None = None,
54
+ spawning_task_id: str | None = None,
55
+ ):
56
+ """Writes results to the cache. This is called after a task is run.
57
+
58
+ :param results: Results to write
59
+ :param task_type: Task type to write -- this is inclucded in case a task returns generators
60
+ """
61
+ pass
62
+
63
+ @abc.abstractmethod
64
+ def read(
65
+ self,
66
+ keys: list[str],
67
+ group_id: str | None = None,
68
+ spawning_task_id: str | None = None,
69
+ optional: bool = False,
70
+ ) -> dict[str, Any]:
71
+ """Reads results in bulk from the cache.
72
+
73
+ :param spawning_task_id: Task ID of the task that spawned the task
74
+ whose results you wish to query
75
+ :param group_id: Group ID of the task whose results you wish to query
76
+ :param keys: Keys to access
77
+ :param optional: If true, will allow keys not to be present
78
+ :return: Dictionary of key -> result
79
+ """
80
+ pass
81
+
82
+
83
+ class DictBasedResultCache(ResultCache):
84
+ """Cache of intermediate results. Will likely want to add pruning to this..."""
85
+
86
+ def __init__(self, cache: dict[str, Any]):
87
+ self.cache = cache
88
+
89
+ def _format_key(self, group_id: str | None, spawning_task_id: str | None, key: str) -> str:
90
+ return ":".join([item for item in [spawning_task_id, group_id, key] if item is not None])
91
+
92
+ def write(
93
+ self,
94
+ results: dict[str, Any],
95
+ group_id: str | None = None,
96
+ spawning_task_id: str | None = None,
97
+ ):
98
+ results_with_key_assigned = {
99
+ self._format_key(group_id, spawning_task_id, key): value
100
+ for key, value in results.items()
101
+ }
102
+ self.cache.update(results_with_key_assigned)
103
+
104
+ def read(
105
+ self,
106
+ keys: list[str],
107
+ group_id: str | None = None,
108
+ spawning_task_id: str | None = None,
109
+ optional: bool = False,
110
+ ) -> dict[str, Any]:
111
+ """Reads results in bulk from the cache. If its optional, we don't mind if its not there.
112
+
113
+ :param keys: Keys to read
114
+ :param group_id: ID of the group (namespace) under which this key will be stored
115
+ :param spawning_task_id: ID of the spawning task (other part of the namespace) under which this
116
+ key will be stored
117
+ :param optional: If true, we don't mind if the key is not there
118
+ :return: Dictionary of key -> result
119
+ """
120
+
121
+ out = {}
122
+ for key in keys:
123
+ formatted_key = self._format_key(group_id, spawning_task_id, key)
124
+ if formatted_key not in self.cache:
125
+ if optional:
126
+ continue
127
+ else:
128
+ raise KeyError(f"Key {formatted_key} not found in cache") # noqa E713
129
+ out[key] = self.cache[formatted_key]
130
+ return out
131
+
132
+
133
+ class ExecutionState:
134
+ """Stores the basic execution state of a DAG. This is responsible for two things:
135
+ 1. Be the source of truth of the execution state
136
+ 2. Tell us what task to run next
137
+ 3. Prep the task for execution (give it the results it needs)
138
+ """
139
+
140
+ def __init__(self, tasks: list[TaskSpec], result_cache: ResultCache, run_id: str):
141
+ """Initializes an ExecutionState to all uninitialized. TBD if we want to add in an initialization
142
+ step that can, say, read from a db.
143
+
144
+
145
+ :param task_graph: Graph of tasks to run.
146
+ :param results_cache: Cache of results
147
+ """
148
+ # Pool of available tasks. Note these get added dynamically as
149
+ # tasks are run, in case we want to split out
150
+ self.result_cache = result_cache
151
+ self.run_id = run_id
152
+ self.task_states = collections.defaultdict(lambda: TaskState.UNINITIALIZED)
153
+ self.task_pool = {}
154
+ self._initialize_task_pool(tasks)
155
+ self.base_task_pool = {}
156
+ self._initialize_base_task_pool(tasks)
157
+ self.task_queue = collections.deque()
158
+ self._initialize_task_queue()
159
+ self.is_initialized = True
160
+ self.base_reverse_dependencies = self.compute_reverse_dependencies(tasks)
161
+
162
+ @staticmethod
163
+ def compute_reverse_dependencies(tasks: list[TaskSpec]) -> dict[str, list[TaskSpec]]:
164
+ """Computes dependencies in reverse order, E.G. what tasks depend on this task.
165
+
166
+ :param tasks:
167
+ :return:
168
+ """
169
+ reverse_dependencies = {task.base_id: [] for task in tasks}
170
+ for task in tasks:
171
+ for dependency in task.base_dependencies:
172
+ reverse_dependencies[dependency].append(task)
173
+ return reverse_dependencies
174
+
175
+ def _initialize_task_pool(self, tasks: list[TaskSpec]):
176
+ """Initializes the task pool to all nodes that have no dependencies.
177
+
178
+ :param tasks:
179
+ :return:
180
+ """
181
+ for task in tasks:
182
+ # Note that this will break with nested tasks...
183
+ # We want to add every task that is initially spawned and "static"
184
+ # We want to add dynamic tasks later
185
+ if len(task.base_dependencies) == 0:
186
+ self.realize_task(task, None, None, None)
187
+
188
+ def _initialize_base_task_pool(self, tasks: list[TaskSpec]):
189
+ """Initializes the base task pool to all nodes.
190
+
191
+ :param tasks:
192
+ :return:
193
+ """
194
+ for task in tasks:
195
+ self.base_task_pool[task.base_id] = task
196
+
197
+ def _initialize_task_queue(self):
198
+ """Initializes the task queue to all nodes that have no dependencies.
199
+
200
+ :param tasks:
201
+ :return:
202
+ """
203
+ for task in self.task_pool.values():
204
+ if len(task.base_dependencies) == 0:
205
+ self.task_queue.append(task)
206
+ self.task_states[task.task_id] = TaskState.QUEUED
207
+
208
+ def realize_task(
209
+ self,
210
+ task_spec: TaskSpec,
211
+ spawning_task: str | None,
212
+ group_id: str | None,
213
+ dependencies: dict[str, list[str]] = None,
214
+ bind: dict[str, Any] = None,
215
+ ):
216
+ """Creates a task and enqueues it to the internal queue. This takes a task in "Plan" state
217
+ (E.G. a TaskSpec), and trasnforms it into execution-ready state, freezing the dependencies.
218
+
219
+ You can think of a TaskSpec/plan as a function (or class), and an TaskImplementation as
220
+ an instance of that function/class.
221
+
222
+ :param base_task:
223
+ :return:
224
+ """
225
+
226
+ realized_dependencies = (
227
+ dependencies
228
+ if dependencies is not None
229
+ else {task_dep: [task_dep] for task_dep in task_spec.base_dependencies}
230
+ )
231
+ task_implementation = TaskImplementation(
232
+ spawning_task_base_id=spawning_task,
233
+ base_id=task_spec.base_id,
234
+ nodes=task_spec.nodes,
235
+ purpose=task_spec.purpose,
236
+ outputs_to_compute=task_spec.outputs_to_compute,
237
+ overrides=task_spec.overrides,
238
+ adapter=task_spec.adapter,
239
+ base_dependencies=task_spec.base_dependencies,
240
+ spawning_task_id=spawning_task,
241
+ group_id=group_id,
242
+ # TODO -- actually get these correct...
243
+ # This just assigns the realized dependencies to be the base dependencies
244
+ realized_dependencies=realized_dependencies,
245
+ run_id=self.run_id,
246
+ )
247
+ if bind is not None:
248
+ task_implementation = task_implementation.bind(bind)
249
+ self.task_pool[task_implementation.task_id] = task_implementation
250
+ self.task_states[task_implementation.task_id] = TaskState.INITIALIZED
251
+ return task_implementation
252
+
253
+ def realize_parameterized_group(
254
+ self,
255
+ spawning_task_id: str,
256
+ parameterizations: dict[str, Any],
257
+ input_to_parameterize: str,
258
+ ) -> list[TaskImplementation]:
259
+ """Parameterizes an unordered expand group. These are tasks that are all part of the same
260
+ group. For every result in the list, the input of the next task gets the result.
261
+
262
+ :param task:
263
+ :param results:
264
+ :return:
265
+ """
266
+ # first we get all future nodes in the group
267
+ out = []
268
+ tasks_to_repeat = [
269
+ task
270
+ for task in self.base_task_pool.values()
271
+ if task.spawning_task_base_id == spawning_task_id
272
+ and not task.purpose.is_expander()
273
+ and not task.purpose.is_gatherer()
274
+ ]
275
+ task_names_in_group = [task.base_id for task in tasks_to_repeat]
276
+ # Then we parameterize them, making the following changes:
277
+ # 1. We replace base dependencies with the actual task ids
278
+ # 2. We replace the inputs of the first task with the results
279
+ # 3. We replace the inputs of the collect task with the outputs of the other tasks, grouped
280
+ # expander_node = completed_task.get_expander_node()
281
+ # # This should be a list of all the parameterizations
282
+ # results_to_bind = list(result_cache.read([expander_node.name]))
283
+ # For every result in the list, we create a new set of tasks
284
+ name_maps = {}
285
+ # Go through every task we need to repeat
286
+ for group_name, result in parameterizations.items():
287
+ new_tasks = []
288
+ # We create a new set of tasks, and add them to the task pool
289
+ name_map = {}
290
+ for task in tasks_to_repeat:
291
+ # We create a new task, with the result bound to the input
292
+ new_task = self.realize_task(
293
+ task, spawning_task_id, group_name, bind={input_to_parameterize: result}
294
+ )
295
+ # new_task = new_task.bind({expander_node.name: result})
296
+ # should bind...
297
+ name_map[task.base_id] = new_task.task_id
298
+ new_tasks.append(new_task)
299
+ for new_task in new_tasks:
300
+ # We replace the dependencies with the new task ids
301
+ new_task.realized_dependencies = {
302
+ dependency: name_map[dependency] if dependency in name_map else [dependency]
303
+ for dependency in new_task.base_dependencies
304
+ }
305
+ # We add the new tasks to the task pool
306
+ out.append(new_task)
307
+ name_maps[group_name] = name_map
308
+ collector_tasks = [
309
+ task
310
+ for task in self.base_task_pool.values()
311
+ if task.purpose.is_gatherer() and task.spawning_task_base_id == spawning_task_id
312
+ ]
313
+
314
+ # Go through every task we need to collect those
315
+ for collector_task in collector_tasks:
316
+ # collector_node = completed_task.get_collector_node()
317
+ # collect_input = collector_node.collect_dependency
318
+ # We create it with no spawning tasks as we don't want to name it differently
319
+ # We should really give it a better unique ID? This is a little hacky
320
+ new_task = self.realize_task(collector_task, None, None)
321
+ new_dependencies = {}
322
+ for dependency in new_task.base_dependencies:
323
+ new_dependencies[dependency] = []
324
+ if dependency in task_names_in_group:
325
+ for _group_name, name_map in name_maps.items():
326
+ new_dependencies[dependency].append(name_map[dependency])
327
+ else:
328
+ new_dependencies[dependency].append(dependency)
329
+ new_task.realized_dependencies = new_dependencies
330
+ out.append(new_task)
331
+ return out
332
+
333
+ def write_task_results(self, writer: TaskImplementation, results: dict[str, Any]):
334
+ results_to_write = results
335
+ if writer.purpose.is_expander():
336
+ # In this case we need to write each result individually
337
+ result_name_for_expansion = writer.get_expander_node().name
338
+ result_for_expansion = list(results_to_write.pop(result_name_for_expansion))
339
+ self.result_cache.write({result_name_for_expansion: result_for_expansion}, None, None)
340
+ # write the rest with the appropriate namespace
341
+ self.result_cache.write(results_to_write, writer.group_id, writer.spawning_task_id)
342
+
343
+ def update_task_state(self, task_id: str, new_state: TaskState, results: dict[str, Any] | None):
344
+ """Updates the state of a task based on an external push.
345
+ This also determines which tasks to create/enqueue next. If the node finished succesfully,
346
+ this has two steps:
347
+
348
+ Node Creation (turning a TaskSpec into a TaskImplementation)
349
+ 1. If its a "expand" node, we first create the children tasks, then the collect that depends
350
+ on them
351
+ 2. Else, we create subsequent tasks (unless they are a "collect" node, in which case they
352
+ should be created by the expand node that spawns them)
353
+
354
+ Node Enqueuing (putting the task into the queue)
355
+ We then enqueue all tasks that (a) depend on them and (b) only have dependencies that are
356
+ now complete.
357
+ :param task_id: ID of task to update state of
358
+ :param new_state: New state to update to.
359
+ """
360
+
361
+ self.task_states[task_id] = new_state
362
+ if not TaskState.is_terminal(new_state):
363
+ return
364
+ logger.debug(
365
+ f"Received update for task {task_id} with state {new_state.value}, "
366
+ f"current queue is {self._format_task_queue()}"
367
+ )
368
+ for item in self.task_queue:
369
+ assert self.task_states[item.task_id] == TaskState.QUEUED, (
370
+ f"Task {item.task_id} is in the queue but not queued, "
371
+ f"state is {self.task_states[item.task_id].value}"
372
+ )
373
+ # Creating task implementations
374
+ task_to_update = self.task_pool[task_id]
375
+ if new_state == TaskState.SUCCESSFUL:
376
+ self.write_task_results(task_to_update, results)
377
+ completed_task: TaskImplementation = self.task_pool[task_id] # First look up the task
378
+ if completed_task.purpose == NodeGroupPurpose.EXPAND_UNORDERED:
379
+ # In this case we need to extract all the tasks we need to spawn
380
+ # This involves the following:
381
+ # 1. For each node, spawn the task, and bind the result to that node
382
+ # 2. Spawn the collect task to depend on all of them that it depends on)
383
+ input_to_parameterize = completed_task.get_expander_node().name
384
+ # We do realization here, which is not ideal, as it outputs a generator The task
385
+ # executor should be able to handle this I think... Likely we want the executor
386
+ # to be able to write its own result cache? In case it has an internal buffer?
387
+ parameterization_results = self.result_cache.read([input_to_parameterize])[
388
+ input_to_parameterize
389
+ ]
390
+ parameterization_values = {
391
+ str(i): item for i, item in enumerate(parameterization_results)
392
+ }
393
+ logger.debug(
394
+ f"Completed an expand step, parameterizing: {input_to_parameterize} "
395
+ f"over values : {parameterization_values}"
396
+ )
397
+ self.realize_parameterized_group(
398
+ completed_task.task_id, parameterization_values, input_to_parameterize
399
+ )
400
+ if completed_task.adapter.does_hook("post_task_expand", is_async=False):
401
+ # TODO -- parameterization_values could be materialized here for generators
402
+ completed_task.adapter.call_all_lifecycle_hooks_sync(
403
+ "post_task_expand",
404
+ run_id=completed_task.run_id,
405
+ task_id=completed_task.task_id,
406
+ parameters=parameterization_values,
407
+ )
408
+ else:
409
+ for candidate_task in self.base_reverse_dependencies[completed_task.base_id]:
410
+ # This means its not spawned by another task, or a node spawning group itself
411
+ if (
412
+ candidate_task.spawning_task_base_id is None
413
+ or candidate_task.purpose.is_expander()
414
+ ):
415
+ # Then we need to spawn it
416
+ # In this case it will have no namespace/group (just standard)
417
+ new_task_id = candidate_task.base_id
418
+ if new_task_id not in self.task_pool:
419
+ # Then we need to create it
420
+ self.realize_task(candidate_task, None, None)
421
+ # TODO -- make this more efficient
422
+ # We need reverse dependencies, but there are ways to do this cleanly
423
+ # For now we'll just loop through
424
+
425
+ tasks_to_enqueue = []
426
+ # not efficient, TODO -- use a reverse dependency map
427
+ for _key, task in self.task_pool.items():
428
+ if self.task_states[task.task_id] == TaskState.INITIALIZED:
429
+ should_launch = True
430
+ for _base_dep_name, realized_dep_list in task.realized_dependencies.items():
431
+ for dep in realized_dep_list:
432
+ if self.task_states[dep] != TaskState.SUCCESSFUL:
433
+ should_launch = False
434
+ if should_launch:
435
+ tasks_to_enqueue.append(task)
436
+ task_names = [task.task_id for task in tasks_to_enqueue]
437
+ if len(task_names) > 0:
438
+ logger.info(
439
+ f"Enqueuing {len(task_names)} task{'s' if len(task_names) > 1 else ''}: "
440
+ f"{', '.join(task_names[:2] + (['...'] if len(task_names) > 2 else []))}"
441
+ )
442
+ for task_to_enqueue in tasks_to_enqueue:
443
+ self.task_queue.append(task_to_enqueue)
444
+ self.task_states[task_to_enqueue.task_id] = TaskState.QUEUED
445
+
446
+ def _format_task_queue(self, verbose: bool = False) -> str:
447
+ if verbose:
448
+ return str([item.task_id for item in self.task_queue])
449
+ state_counts = collections.Counter(
450
+ [self.task_states[item.task_id].value for item in self.task_queue]
451
+ )
452
+ return str(dict(state_counts))
453
+
454
+ def get_graph_state(self) -> GraphState:
455
+ """Gives the state of the graph, which can be derived from the state of the tasks.
456
+
457
+ :return: State of the graph
458
+ """
459
+ if not self.is_initialized:
460
+ return GraphState.UNINITIALIZED
461
+ elif len(self.task_states) == 0 or all(
462
+ [state == TaskState.INITIALIZED for state in self.task_states.values()]
463
+ ):
464
+ return GraphState.INITIALIZED
465
+ elif all([state == TaskState.SUCCESSFUL for state in self.task_states.values()]):
466
+ return GraphState.SUCCESSFUL
467
+ elif any([state == TaskState.FAILED for state in self.task_states.values()]):
468
+ # if we want to execute until completion, then we should return RUNNING here
469
+ # Unless they're all terminal, in which case we should return FAILED
470
+ return GraphState.FAILED
471
+ else:
472
+ return GraphState.RUNNING
473
+
474
+ def bind_task(self, task: TaskImplementation):
475
+ required_input_vars, optional_input_vars = task.get_input_vars()
476
+ dynamic_inputs = task.overrides.copy()
477
+ if task.purpose.is_gatherer():
478
+ # We need to intelligently bind the arguments from its dependencies
479
+ collector_node = task.get_collector_node()
480
+ collector_arg = collector_node.collect_dependency
481
+ dynamic_inputs = {collector_arg: []}
482
+ for dependency_base, dependencies in task.realized_dependencies.copy().items():
483
+ # We know that if one has the collector node, they all will
484
+ # As that's how we run it
485
+ if len(dependencies) == 0:
486
+ # This is a parameterized task, so we'll just end up passing an empty array, and not computing anything from it
487
+ continue
488
+ produces_collector_arg = self.task_pool[dependencies[0]].produces(collector_arg)
489
+ if produces_collector_arg:
490
+ for dependency in dependencies:
491
+ dependent_task = self.task_pool[dependency]
492
+ dynamic_inputs[collector_arg].append(
493
+ self.result_cache.read(
494
+ [collector_arg],
495
+ dependent_task.group_id,
496
+ dependent_task.spawning_task_id,
497
+ )[collector_arg]
498
+ )
499
+ del task.realized_dependencies[dependency_base]
500
+ # Then we go through the rest -- these just fill in the other args
501
+ input_vars_to_read = [
502
+ item
503
+ for item in required_input_vars
504
+ if item not in dynamic_inputs and item not in task.overrides
505
+ ]
506
+ dynamic_inputs = {**dynamic_inputs, **self.result_cache.read(input_vars_to_read)}
507
+ dynamic_inputs = {
508
+ **dynamic_inputs,
509
+ **self.result_cache.read(optional_input_vars, optional=True),
510
+ }
511
+ return task.bind(dynamic_inputs)
512
+
513
+ def release_next_task(self) -> TaskImplementation | None:
514
+ """Gives the next task to run, and inserts inputs it needs to run.
515
+ Note that this can return None, which means there is nothing to run. That indicates that,
516
+ either:
517
+
518
+ 1. The graph is done computing
519
+ 2. All tasks are currently blocked
520
+
521
+ The caller is responsible for no longer calling this when get_graph_state() is in a
522
+ terminal position.
523
+
524
+ TODO -- consider finalizing (not binding) a task to save compute/external resources.
525
+
526
+ :return: None if there is no task
527
+ """
528
+ if len(self.task_queue) == 0:
529
+ return None
530
+ return self.bind_task(self.task_queue.popleft())
531
+
532
+ def reject_task(self, task_to_reject: TaskImplementation):
533
+ """If the task just released is not accepted, we're just going to place
534
+ it back on the front of the queue. We'll likely want to redo these, but
535
+ for now this will be OK.
536
+
537
+ :param task_to_reject: Task we got from release_next_task that we're rejecting.
538
+ """
539
+ self.task_queue.appendleft(task_to_reject)
@@ -0,0 +1,27 @@
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
+ The python modules found here should never be automatically imported.
20
+
21
+ The user should always refer to them as
22
+
23
+ from hamilton.experimental import <MODULE>
24
+
25
+ That way, if there are any esoteric dependencies, they are pulled in when
26
+ requested by the user, rather than automatically when they import hamilton.
27
+ """
@@ -0,0 +1,61 @@
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
+ """Below is a file from `databackend` v0.0.2 found on GitHub
19
+ permalink: https://github.com/machow/databackend/blob/214832bab6990098d52a4a2fae935f5d9ae8dc3c/databackend/__init__.py
20
+ """
21
+
22
+ # Copyright (c) 2024 databackend contributors (MIT License)
23
+ #
24
+ # See https://github.com/machow/databackend
25
+
26
+ import importlib
27
+ import sys
28
+ from abc import ABCMeta
29
+
30
+
31
+ def _load_class(mod_name: str, cls_name: str):
32
+ mod = importlib.import_module(mod_name)
33
+ return getattr(mod, cls_name)
34
+
35
+
36
+ class _AbstractBackendMeta(ABCMeta):
37
+ def register_backend(cls, mod_name: str, cls_name: str):
38
+ cls._backends.append((mod_name, cls_name))
39
+ cls._abc_caches_clear()
40
+
41
+
42
+ class AbstractBackend(metaclass=_AbstractBackendMeta):
43
+ @classmethod
44
+ def __init_subclass__(cls):
45
+ if not hasattr(cls, "_backends"):
46
+ cls._backends = []
47
+
48
+ @classmethod
49
+ def __subclasshook__(cls, subclass):
50
+ for mod_name, cls_name in cls._backends:
51
+ if mod_name not in sys.modules:
52
+ # module isn't loaded, so it can't be the subclass
53
+ # we don't want to import the module to explicitly run the check
54
+ # so skip here.
55
+ continue
56
+ else:
57
+ parent_candidate = _load_class(mod_name, cls_name)
58
+ if issubclass(subclass, parent_candidate):
59
+ return True
60
+
61
+ return NotImplemented
@@ -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.