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.
- apache_hamilton-1.90.0.dev0.dist-info/METADATA +407 -0
- apache_hamilton-1.90.0.dev0.dist-info/RECORD +151 -0
- apache_hamilton-1.90.0.dev0.dist-info/WHEEL +4 -0
- apache_hamilton-1.90.0.dev0.dist-info/entry_points.txt +9 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/DISCLAIMER +10 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/LICENSE +228 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/NOTICE +5 -0
- hamilton/__init__.py +24 -0
- hamilton/ad_hoc_utils.py +132 -0
- hamilton/async_driver.py +465 -0
- hamilton/base.py +466 -0
- hamilton/caching/__init__.py +16 -0
- hamilton/caching/adapter.py +1475 -0
- hamilton/caching/cache_key.py +70 -0
- hamilton/caching/fingerprinting.py +287 -0
- hamilton/caching/stores/__init__.py +16 -0
- hamilton/caching/stores/base.py +242 -0
- hamilton/caching/stores/file.py +140 -0
- hamilton/caching/stores/memory.py +297 -0
- hamilton/caching/stores/sqlite.py +282 -0
- hamilton/caching/stores/utils.py +40 -0
- hamilton/cli/__init__.py +16 -0
- hamilton/cli/__main__.py +328 -0
- hamilton/cli/commands.py +126 -0
- hamilton/cli/logic.py +338 -0
- hamilton/common/__init__.py +76 -0
- hamilton/contrib/__init__.py +41 -0
- hamilton/data_quality/__init__.py +16 -0
- hamilton/data_quality/base.py +198 -0
- hamilton/data_quality/default_validators.py +560 -0
- hamilton/data_quality/pandera_validators.py +121 -0
- hamilton/dataflows/__init__.py +726 -0
- hamilton/dataflows/template/README.md +25 -0
- hamilton/dataflows/template/__init__.py +54 -0
- hamilton/dataflows/template/author.md +28 -0
- hamilton/dataflows/template/requirements.txt +0 -0
- hamilton/dataflows/template/tags.json +7 -0
- hamilton/dataflows/template/valid_configs.jsonl +1 -0
- hamilton/dev_utils/__init__.py +16 -0
- hamilton/dev_utils/deprecation.py +204 -0
- hamilton/driver.py +2112 -0
- hamilton/execution/__init__.py +16 -0
- hamilton/execution/debugging_utils.py +56 -0
- hamilton/execution/executors.py +502 -0
- hamilton/execution/graph_functions.py +421 -0
- hamilton/execution/grouping.py +430 -0
- hamilton/execution/state.py +539 -0
- hamilton/experimental/__init__.py +27 -0
- hamilton/experimental/databackend.py +61 -0
- hamilton/experimental/decorators/__init__.py +16 -0
- hamilton/experimental/decorators/parameterize_frame.py +233 -0
- hamilton/experimental/h_async.py +29 -0
- hamilton/experimental/h_cache.py +413 -0
- hamilton/experimental/h_dask.py +28 -0
- hamilton/experimental/h_databackends.py +174 -0
- hamilton/experimental/h_ray.py +28 -0
- hamilton/experimental/h_spark.py +32 -0
- hamilton/function_modifiers/README +40 -0
- hamilton/function_modifiers/__init__.py +121 -0
- hamilton/function_modifiers/adapters.py +900 -0
- hamilton/function_modifiers/base.py +859 -0
- hamilton/function_modifiers/configuration.py +310 -0
- hamilton/function_modifiers/delayed.py +202 -0
- hamilton/function_modifiers/dependencies.py +246 -0
- hamilton/function_modifiers/expanders.py +1230 -0
- hamilton/function_modifiers/macros.py +1634 -0
- hamilton/function_modifiers/metadata.py +434 -0
- hamilton/function_modifiers/recursive.py +908 -0
- hamilton/function_modifiers/validation.py +289 -0
- hamilton/function_modifiers_base.py +31 -0
- hamilton/graph.py +1153 -0
- hamilton/graph_types.py +264 -0
- hamilton/graph_utils.py +41 -0
- hamilton/htypes.py +450 -0
- hamilton/io/__init__.py +32 -0
- hamilton/io/data_adapters.py +216 -0
- hamilton/io/default_data_loaders.py +224 -0
- hamilton/io/materialization.py +500 -0
- hamilton/io/utils.py +158 -0
- hamilton/lifecycle/__init__.py +67 -0
- hamilton/lifecycle/api.py +833 -0
- hamilton/lifecycle/base.py +1130 -0
- hamilton/lifecycle/default.py +802 -0
- hamilton/log_setup.py +47 -0
- hamilton/models.py +92 -0
- hamilton/node.py +449 -0
- hamilton/plugins/README.md +48 -0
- hamilton/plugins/__init__.py +16 -0
- hamilton/plugins/dask_extensions.py +47 -0
- hamilton/plugins/dlt_extensions.py +161 -0
- hamilton/plugins/geopandas_extensions.py +49 -0
- hamilton/plugins/h_dask.py +331 -0
- hamilton/plugins/h_ddog.py +522 -0
- hamilton/plugins/h_diskcache.py +163 -0
- hamilton/plugins/h_experiments/__init__.py +22 -0
- hamilton/plugins/h_experiments/__main__.py +62 -0
- hamilton/plugins/h_experiments/cache.py +39 -0
- hamilton/plugins/h_experiments/data_model.py +68 -0
- hamilton/plugins/h_experiments/hook.py +219 -0
- hamilton/plugins/h_experiments/server.py +375 -0
- hamilton/plugins/h_kedro.py +152 -0
- hamilton/plugins/h_logging.py +454 -0
- hamilton/plugins/h_mcp/__init__.py +28 -0
- hamilton/plugins/h_mcp/__main__.py +33 -0
- hamilton/plugins/h_mcp/_helpers.py +129 -0
- hamilton/plugins/h_mcp/_templates.py +417 -0
- hamilton/plugins/h_mcp/server.py +328 -0
- hamilton/plugins/h_mlflow.py +335 -0
- hamilton/plugins/h_narwhals.py +134 -0
- hamilton/plugins/h_openlineage.py +400 -0
- hamilton/plugins/h_opentelemetry.py +167 -0
- hamilton/plugins/h_pandas.py +257 -0
- hamilton/plugins/h_pandera.py +117 -0
- hamilton/plugins/h_polars.py +304 -0
- hamilton/plugins/h_polars_lazyframe.py +282 -0
- hamilton/plugins/h_pyarrow.py +56 -0
- hamilton/plugins/h_pydantic.py +127 -0
- hamilton/plugins/h_ray.py +242 -0
- hamilton/plugins/h_rich.py +142 -0
- hamilton/plugins/h_schema.py +493 -0
- hamilton/plugins/h_slack.py +100 -0
- hamilton/plugins/h_spark.py +1380 -0
- hamilton/plugins/h_threadpool.py +125 -0
- hamilton/plugins/h_tqdm.py +122 -0
- hamilton/plugins/h_vaex.py +129 -0
- hamilton/plugins/huggingface_extensions.py +236 -0
- hamilton/plugins/ibis_extensions.py +93 -0
- hamilton/plugins/jupyter_magic.py +622 -0
- hamilton/plugins/kedro_extensions.py +117 -0
- hamilton/plugins/lightgbm_extensions.py +99 -0
- hamilton/plugins/matplotlib_extensions.py +108 -0
- hamilton/plugins/mlflow_extensions.py +216 -0
- hamilton/plugins/numpy_extensions.py +105 -0
- hamilton/plugins/pandas_extensions.py +1763 -0
- hamilton/plugins/plotly_extensions.py +150 -0
- hamilton/plugins/polars_extensions.py +71 -0
- hamilton/plugins/polars_implementations.py +25 -0
- hamilton/plugins/polars_lazyframe_extensions.py +302 -0
- hamilton/plugins/polars_post_1_0_0_extensions.py +877 -0
- hamilton/plugins/polars_pre_1_0_0_extension.py +836 -0
- hamilton/plugins/pydantic_extensions.py +98 -0
- hamilton/plugins/pyspark_pandas_extensions.py +47 -0
- hamilton/plugins/sklearn_plot_extensions.py +129 -0
- hamilton/plugins/spark_extensions.py +105 -0
- hamilton/plugins/vaex_extensions.py +51 -0
- hamilton/plugins/xgboost_extensions.py +91 -0
- hamilton/plugins/yaml_extensions.py +89 -0
- hamilton/registry.py +254 -0
- hamilton/settings.py +18 -0
- hamilton/telemetry.py +50 -0
- 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.
|