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,802 @@
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
+ """A selection of default lifeycle hooks/methods that come with Hamilton. These carry no additional requirements"""
19
+
20
+ import hashlib
21
+ import logging
22
+ import pdb
23
+ import pickle
24
+ import pprint
25
+ import random
26
+ import shelve
27
+ import time
28
+ from collections.abc import Callable
29
+ from functools import partial
30
+ from typing import Any, Union
31
+
32
+ from hamilton import graph_types, htypes
33
+ from hamilton.graph_types import HamiltonGraph
34
+ from hamilton.lifecycle import (
35
+ EdgeConnectionHook,
36
+ GraphExecutionHook,
37
+ NodeExecutionHook,
38
+ NodeExecutionMethod,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ NodeFilter = Union[
44
+ Callable[
45
+ [str, dict[str, Any]], bool
46
+ ], # filter function for nodes, mapping node name to a boolean
47
+ list[str], # list of node names to run
48
+ str, # node name to run
49
+ None, # run all nodes
50
+ ] # filter function for nodes, mapping node name and node tags to a boolean
51
+
52
+
53
+ def should_run_node(node_name: str, node_tags: dict[str, Any], node_filter: NodeFilter) -> bool:
54
+ if node_filter is None:
55
+ return True
56
+ if isinstance(node_filter, str):
57
+ return node_name == node_filter
58
+ if isinstance(node_filter, list):
59
+ return node_name in node_filter
60
+ if callable(node_filter):
61
+ return node_filter(node_name, node_tags)
62
+ raise ValueError(f"Invalid node filter: {node_filter}")
63
+
64
+
65
+ class PrintLn(NodeExecutionHook):
66
+ """Basic hook to print out information before/after node execution."""
67
+
68
+ NODE_TIME_STATE = "node_time"
69
+
70
+ @staticmethod
71
+ def _format_time_delta(delta: float) -> str:
72
+ """Formats a time delta into a human-readable string."""
73
+ # Determine the appropriate unit and format
74
+ if delta < 0.001: # Less than 1 millisecond
75
+ return f"{delta * 1e6:.3g}μs"
76
+ elif delta < 1: # Less than 1 second
77
+ return f"{delta * 1e3:.3g}ms"
78
+ return f"{delta:.3g}s"
79
+
80
+ @staticmethod
81
+ def _validate_verbosity(verbosity: int):
82
+ """Validates that the verbosity is one of [1, 2]"""
83
+ if verbosity not in [1, 2]:
84
+ raise ValueError(f"Verbosity must be one of [1, 2], got {verbosity}")
85
+
86
+ @staticmethod
87
+ def _format_node_name(node_name: str, task_id: str | None) -> str:
88
+ """Formats a node name and task id into a unique node name."""
89
+ if task_id is not None:
90
+ return f"{task_id}:{node_name}"
91
+ return node_name
92
+
93
+ def __init__(
94
+ self,
95
+ verbosity: int = 1,
96
+ print_fn: Callable[[str], None] = print,
97
+ node_filter: NodeFilter = None,
98
+ ):
99
+ """Prints out information before/after node execution.
100
+
101
+ :param verbosity: The verbosity level to print out at.
102
+ `verbosity=1` Print out just the node name and time it took to execute
103
+ `verbosity=2`. Print out inputs of the node + results on execute
104
+ :param print_fn: A function that takes a string and prints it out -- defaults to print. Pass in a logger function, etc... if you so choose.
105
+ :param node_filter: A function that takes a node name and a node tags dict and returns a boolean. If the boolean is True, the node will be printed out.
106
+ If False, it will not be printed out.
107
+ """
108
+ PrintLn._validate_verbosity(verbosity)
109
+ self.verbosity = verbosity
110
+ self.print_fn = print_fn
111
+ self.timer_dict = {} # quick dict to track the time it took to execute a node
112
+ if node_filter is None:
113
+ node_filter = lambda node_name, node_tags: True # noqa E731
114
+ self.node_filter = node_filter
115
+
116
+ def run_before_node_execution(
117
+ self,
118
+ *,
119
+ node_name: str,
120
+ node_tags: dict[str, Any],
121
+ node_kwargs: dict[str, Any],
122
+ task_id: str | None,
123
+ **future_kwargs: Any,
124
+ ):
125
+ """Runs before a node executes. Prints out the node name and inputs if verbosity is 2.
126
+
127
+ :param node_name: Name of the node
128
+ :param node_tags: Tags of the node
129
+ :param node_kwargs: Keyword arguments of the node
130
+ :param task_id: ID of the task that the node is in, if any
131
+ :param future_kwargs: Additional keyword arguments that may be passed to the hook yet are ignored for now
132
+ """
133
+ if not should_run_node(node_name, node_tags, self.node_filter):
134
+ return
135
+ node_unique_id = self._format_node_name(node_name, task_id)
136
+ self.timer_dict[node_unique_id] = time.time()
137
+ message = f"Executing node: {node_unique_id}."
138
+ if self.verbosity == 2:
139
+ message += f" Inputs: \n{pprint.pformat(node_kwargs)}"
140
+ self.print_fn(message)
141
+
142
+ def run_after_node_execution(
143
+ self,
144
+ *,
145
+ node_name: str,
146
+ node_tags: dict[str, Any],
147
+ node_kwargs: dict[str, Any],
148
+ result: Any,
149
+ error: Exception | None,
150
+ success: bool,
151
+ task_id: str | None,
152
+ **future_kwargs: Any,
153
+ ):
154
+ """Runs after a node executes. Prints out the node name and time it took, the output if verbosity is 1.
155
+
156
+ :param node_name: Name of the node
157
+ :param node_tags: Tags of the node
158
+ :param node_kwargs: Keyword arguments passed to the node
159
+ :param result: Result of the node
160
+ :param error: Error of the node
161
+ :param success: Whether the node was successful or not
162
+ :param task_id: ID of the task that the node is in, if any
163
+ :param future_kwargs: Additional keyword arguments that may be passed to the hook yet are ignored for now
164
+ """
165
+ if not should_run_node(node_name, node_tags, self.node_filter):
166
+ return
167
+ node_unique_id = self._format_node_name(node_name, task_id)
168
+ time_delta = time.time() - self.timer_dict[node_unique_id]
169
+ time_delta_formatted = self._format_time_delta(time_delta)
170
+ message = f"Finished debugging node: {node_unique_id} in {time_delta_formatted}. Status: {'Success' if success else 'Failure'}."
171
+ if self.verbosity == 2:
172
+ if success:
173
+ message += f" Result: \n{pprint.pformat(result)}\n"
174
+ else:
175
+ message += f" Error: \n{pprint.pformat(error)}"
176
+ self.print_fn(message)
177
+ del self.timer_dict[node_name]
178
+
179
+
180
+ class PDBDebugger(NodeExecutionHook, NodeExecutionMethod):
181
+ """Class to inject a PDB debugger into a node execution. This is still somewhat experimental as it is a debugging utility.
182
+ We reserve the right to change the API and the implementation of this class in the future.
183
+ """
184
+
185
+ CONTEXT = dict()
186
+
187
+ def __init__(
188
+ self,
189
+ node_filter: NodeFilter,
190
+ before: bool = False,
191
+ during: bool = False,
192
+ after: bool = False,
193
+ ):
194
+ """Creates a PDB debugger. This has three possible modes:
195
+ 1. Before -- places you in a function with (a) node information, and (b) inputs
196
+ 2. During -- runs the node with pdb.run. Note this may not always work or give what you expect as
197
+ node functions are often wrapped in multiple levels of input modifications/whatnot. That said, it should give you something.
198
+ Also note that this is not (currently) compatible with graph adapters.
199
+ 3. After -- places you in a function with (a) node information, (b) inputs, and (c) results
200
+
201
+
202
+ :param node_filter: A function that takes a node name and a node tags dict and returns a boolean. If the boolean is True, the node will be printed out.
203
+ :param before: Whether to place you in a PDB debugger before a node executes
204
+ :param during: Whether to place you in a PDB debugger during a node's execution
205
+ :param after: Whether to place you in a PDB debugger after a node executes
206
+ """
207
+ self.node_filter = node_filter
208
+ self.run_before = before
209
+ self.run_during = during
210
+ self.run_after = after
211
+
212
+ def run_to_execute_node(
213
+ self,
214
+ *,
215
+ node_name: str,
216
+ node_tags: dict[str, Any],
217
+ node_callable: Any,
218
+ node_kwargs: dict[str, Any],
219
+ task_id: str | None,
220
+ **future_kwargs: Any,
221
+ ) -> Any:
222
+ """Executes the node with a PDB debugger. This modifies the global PDBDebugger.CONTEXT variable to contain information about the node,
223
+ so you can access it while debugging.
224
+
225
+
226
+ :param node_name: Name of the node
227
+ :param node_tags: Tags of the node
228
+ :param node_callable: Callable function of the node
229
+ :param node_kwargs: Keyword arguments passed to the node
230
+ :param task_id: ID of the task that the node is in, if any
231
+ :param future_kwargs: Additional keyword arguments that may be passed to the hook yet are ignored for now
232
+ :return: Result of the node
233
+ """
234
+ if not should_run_node(node_name, node_tags, self.node_filter) or not self.run_during:
235
+ return node_callable(**node_kwargs)
236
+ PDBDebugger.CONTEXT = {
237
+ "node_name": node_name,
238
+ "node_tags": node_tags,
239
+ "node_callable": node_callable,
240
+ "node_kwargs": node_kwargs,
241
+ "task_id": task_id,
242
+ "future_kwargs": future_kwargs,
243
+ }
244
+ logger.warning(
245
+ f"Placing you in a PDB debugger for node {node_name}."
246
+ "\nYou can access additional node information via PDBDebugger.CONTEXT. Data is:"
247
+ f"\n - node_name: {PDBDebugger._truncate_repr(node_name)}"
248
+ f"\n - node_tags: {PDBDebugger._truncate_repr(node_tags)}"
249
+ f"\n - node_callable: {PDBDebugger._truncate_repr(node_callable)}"
250
+ f"\n - node_kwargs: {PDBDebugger._truncate_repr(', '.join(list(node_kwargs.keys())))}"
251
+ f"\n - task_id: {PDBDebugger._truncate_repr(task_id)}"
252
+ f"\n - future_kwargs: {PDBDebugger._truncate_repr(future_kwargs)}"
253
+ )
254
+ out = pdb.runcall(node_callable, **node_kwargs)
255
+ logger.info(f"Finished executing node {node_name}.")
256
+ return out
257
+
258
+ @staticmethod
259
+ def _truncate_repr(obj: Any, num_chars: int = 80) -> str:
260
+ """Truncates the repr of an object to 100 characters."""
261
+ if isinstance(obj, str):
262
+ return obj[:num_chars]
263
+ return repr(obj)[:num_chars]
264
+
265
+ def run_before_node_execution(
266
+ self,
267
+ *,
268
+ node_name: str,
269
+ node_tags: dict[str, Any],
270
+ node_kwargs: dict[str, Any],
271
+ node_return_type: type,
272
+ task_id: str | None,
273
+ **future_kwargs: Any,
274
+ ):
275
+ """Executes before a node executes. Does nothing, just runs pdb.set_trace()
276
+
277
+ :param node_name: Name of the node
278
+ :param node_tags: Tags of the node
279
+ :param node_kwargs: Keyword arguments passed to the node
280
+ :param node_return_type: Return type of the node
281
+ :param task_id: ID of the task that the node is in, if any
282
+ :param future_kwargs: Additional keyword arguments that may be passed to the hook yet are ignored for now
283
+ :return: Result of the node
284
+ """
285
+ if should_run_node(node_name, node_tags, self.node_filter) and self.run_before:
286
+ logger.warning(
287
+ f"Placing you in a PDB debugger prior to execution of node: {node_name}."
288
+ "\nYou can access additional node information via the following variables:"
289
+ f"\n - node_name: {PDBDebugger._truncate_repr(node_name)}"
290
+ f"\n - node_tags: {PDBDebugger._truncate_repr(node_tags)}"
291
+ f"\n - node_kwargs: {PDBDebugger._truncate_repr(', '.join(list(node_kwargs.keys())))}"
292
+ f"\n - node_return_type: {PDBDebugger._truncate_repr(node_return_type)}"
293
+ f"\n - task_id: {PDBDebugger._truncate_repr(task_id)}"
294
+ )
295
+ pdb.set_trace()
296
+
297
+ def run_after_node_execution(
298
+ self,
299
+ *,
300
+ node_name: str,
301
+ node_tags: dict[str, Any],
302
+ node_kwargs: dict[str, Any],
303
+ node_return_type: type,
304
+ result: Any,
305
+ error: Exception | None,
306
+ success: bool,
307
+ task_id: str | None,
308
+ **future_kwargs: Any,
309
+ ):
310
+ """Executes after a node, whether or not it was successful. Does nothing, just runs pdb.set_trace().
311
+
312
+ :param node_name: Name of the node
313
+ :param node_tags: Tags of the node
314
+ :param node_kwargs: Keyword arguments passed to the node
315
+ :param node_return_type: Return type of the node
316
+ :param result: Result of the node, None if there was an error
317
+ :param error: Error of the node, None if there was no error
318
+ :param success: Whether the node ran successful or not
319
+ :param task_id: Task ID of the node, if any
320
+ :param future_kwargs: Additional keyword arguments that may be passed to the hook yet are ignored for now
321
+ """
322
+ if should_run_node(node_name, node_tags, self.node_filter) and self.run_after:
323
+ logger.warning(
324
+ f"Placing you in a PDB debugger post execution of node: {node_name}."
325
+ "\nYou can access additional node information via the following variables:"
326
+ f"\n - node_name: {PDBDebugger._truncate_repr(node_name)}"
327
+ f"\n - node_tags: {PDBDebugger._truncate_repr(node_tags)}"
328
+ f"\n - node_kwargs: {PDBDebugger._truncate_repr(', '.join(list(node_kwargs.keys())))}"
329
+ f"\n - node_return_type: {PDBDebugger._truncate_repr(node_return_type)}"
330
+ f"\n - result: {PDBDebugger._truncate_repr(result)}"
331
+ f"\n - error: {PDBDebugger._truncate_repr(error)}"
332
+ f"\n - success: {PDBDebugger._truncate_repr(success)}"
333
+ f"\n - task_id: {PDBDebugger._truncate_repr(task_id)}"
334
+ )
335
+ pdb.set_trace()
336
+
337
+
338
+ class CacheAdapter(NodeExecutionHook, NodeExecutionMethod, GraphExecutionHook):
339
+ """Class to cache node results on disk with a key based on the node code implementation and inputs.
340
+ Following runs with the same key can load node results and skip computation.
341
+
342
+ The cache `_nodes_history` entry returns an append-only list of results added to the cache.
343
+ e.g., the last value in the list `cache["_node_history"][node_name]` is the most recent cached node.
344
+
345
+ Notes:
346
+ - It uses the stdlib `shelve` module and the pickle format, which makes results dependent
347
+ on the Python version. Use materialization for persistent results
348
+ - There are no utility to manage cache size so you'll have to delete it periodically. Look
349
+ at the diskcache plugin for Hamilton `hamilton.plugins.h_diskcache` for better cache management.
350
+ """
351
+
352
+ nodes_history_key: str = "_nodes_history"
353
+
354
+ def __init__(self, cache_vars: list[str] | None = None, cache_path: str = "./hamilton-cache"):
355
+ """Initialize the cache
356
+
357
+ :param cache_vars: List of nodes for which to store/load results. Passing None will use the cache
358
+ for all nodes. Default is None.
359
+ :param cache_path: File path to the cache. The file name doesn't need an extension.
360
+ """
361
+ self.cache_vars = cache_vars or []
362
+ self.cache_path = cache_path
363
+ self.cache = shelve.open(self.cache_path)
364
+ self.nodes_history: dict[str, list[str]] = self.cache.get(
365
+ key=CacheAdapter.nodes_history_key, default=dict()
366
+ )
367
+ self.used_nodes_hash: dict[str, str] = dict()
368
+ self.cache.close()
369
+
370
+ logger.warning(
371
+ "The `CacheAdapter` is deprecated and will be removed in Hamilton 2.0. "
372
+ "Consider enabling the core caching feature via `Builder.with_cache()`. "
373
+ "This might not be 1-to-1 replacement, so please reach out if there are missing features. "
374
+ "See https://hamilton.apache.org/concepts/caching/ to learn more."
375
+ )
376
+
377
+ def run_before_graph_execution(self, *, graph: HamiltonGraph, **kwargs):
378
+ """Set `cache_vars` to all nodes if received None during `__init__`"""
379
+ self.cache = shelve.open(self.cache_path)
380
+ if len(self.cache_vars) == 0:
381
+ self.cache_vars = [n.name for n in graph.nodes]
382
+
383
+ def run_to_execute_node(
384
+ self, *, node_name: str, node_callable: Any, node_kwargs: dict[str, Any], **kwargs
385
+ ):
386
+ """Create cache key based on node callable hash (equiv. to HamiltonNode.version) and
387
+ the node inputs (`node_kwargs`).If key in cache (cache hit), load result; else (cache miss),
388
+ compute the node and append node name to `used_nodes_hash`.
389
+
390
+ Note:
391
+ - the callable hash is stored in `used_nodes_hash` because it's required to create the
392
+ key in `run_after_node_execution` and the callable won't be accessible to recompute it
393
+ """
394
+ if node_name not in self.cache_vars:
395
+ return node_callable(**node_kwargs)
396
+
397
+ source_of_node_callable = node_callable
398
+ while isinstance(source_of_node_callable, partial): # handle partials
399
+ source_of_node_callable = source_of_node_callable.func
400
+ node_hash = graph_types.hash_source_code(source_of_node_callable, strip=True)
401
+ cache_key = CacheAdapter.create_key(node_hash, node_kwargs)
402
+
403
+ from_cache = self.cache.get(cache_key, None)
404
+ if from_cache is not None:
405
+ return from_cache
406
+
407
+ self.used_nodes_hash[node_name] = node_hash
408
+ self.nodes_history[node_name] = self.nodes_history.get(node_name, []) + [node_hash]
409
+ return node_callable(**node_kwargs)
410
+
411
+ def run_after_node_execution(
412
+ self, *, node_name: str, node_kwargs: dict[str, Any], result: Any, **kwargs
413
+ ):
414
+ """If `run_to_execute_node` was a cache miss (hash stored in `used_nodes_hash`),
415
+ store the computed result in cache
416
+ """
417
+ if node_name not in self.cache_vars:
418
+ return
419
+
420
+ node_hash = self.used_nodes_hash.get(node_name)
421
+ if node_hash is None:
422
+ return
423
+
424
+ cache_key = CacheAdapter.create_key(node_hash, node_kwargs)
425
+ self.cache[cache_key] = result
426
+
427
+ def run_after_graph_execution(self, *args, **kwargs):
428
+ """After completing execution, overwrite nodes_history_key in cache and close"""
429
+ # TODO updating `nodes_history` at graph completion instead of after node execution
430
+ # means a desync is possible if the graph fails. Could lead to missing keys in
431
+ # `nodes_history`
432
+ self.cache[CacheAdapter.nodes_history_key] = self.nodes_history
433
+ self.cache.close()
434
+
435
+ def run_before_node_execution(self, *args, **kwargs):
436
+ """Placeholder required to subclass `NodeExecutionMethod`"""
437
+ pass
438
+
439
+ @staticmethod
440
+ def create_key(node_hash: str, node_inputs: dict[str, Any]) -> str:
441
+ """Pickle objects into bytes then get their hash value"""
442
+ digest = hashlib.sha256()
443
+ digest.update(node_hash.encode())
444
+
445
+ for ins in node_inputs.values():
446
+ digest.update(pickle.dumps(ins))
447
+
448
+ return digest.hexdigest()
449
+
450
+
451
+ def wait_random(mean: float, stddev: float):
452
+ sleep_time = random.gauss(mu=mean, sigma=stddev)
453
+ if sleep_time < 0:
454
+ sleep_time = 0
455
+ time.sleep(sleep_time)
456
+
457
+
458
+ class SlowDownYouMoveTooFast(NodeExecutionHook):
459
+ """This hook makes your DAG run slower. Just pass in a negative value for the sleep time to make it go faster...
460
+ In all seriousness though, there is absolutely no (good) reason to use this hook. Its dumb, and just for testing.
461
+ """
462
+
463
+ def __init__(self, sleep_time_mean: float, sleep_time_std: float):
464
+ """In all seriousness, don't use this
465
+
466
+ :param sleep_time_mean: Mean of sleep time
467
+ :param sleep_time_std: Stddev of sleep time
468
+ """
469
+ self.sleep_time_mean = sleep_time_mean
470
+ self.sleep_time_std = sleep_time_std
471
+
472
+ def run_before_node_execution(self, **future_kwargs: Any):
473
+ """Waits for a fixed set of time before node execution"""
474
+ wait_random(self.sleep_time_mean, self.sleep_time_std)
475
+
476
+ def run_after_node_execution(self, **future_kwargs: Any):
477
+ """Does nothing"""
478
+ pass
479
+
480
+
481
+ class FunctionInputOutputTypeChecker(NodeExecutionHook):
482
+ """This lifecycle hook checks the input and output types of a function.
483
+
484
+ It is a simple, but very strict type check against the declared type with what was actually received.
485
+ E.g. if you don't want to check the types of a dictionary, don't annotate it with a type.
486
+ """
487
+
488
+ def __init__(self, check_input: bool = True, check_output: bool = True):
489
+ """Constructor.
490
+
491
+ :param check_input: check inputs to all functions
492
+ :param check_output: check outputs to all functions
493
+ """
494
+ self.check_input = check_input
495
+ self.check_output = check_output
496
+
497
+ def run_before_node_execution(
498
+ self,
499
+ node_name: str,
500
+ node_tags: dict[str, Any],
501
+ node_kwargs: dict[str, Any],
502
+ node_return_type: type,
503
+ task_id: str | None,
504
+ run_id: str,
505
+ node_input_types: dict[str, Any],
506
+ **future_kwargs: Any,
507
+ ):
508
+ """Checks that the result type matches the expected node return type."""
509
+ if self.check_input:
510
+ for input_name, input_value in node_kwargs.items():
511
+ if not htypes.check_instance(input_value, node_input_types[input_name]):
512
+ raise TypeError(
513
+ f"Node {node_name} received an input of type {type(input_value)} for {input_name}, expected {node_input_types[input_name]}"
514
+ )
515
+
516
+ def run_after_node_execution(
517
+ self,
518
+ node_name: str,
519
+ node_tags: dict[str, Any],
520
+ node_kwargs: dict[str, Any],
521
+ node_return_type: type,
522
+ result: Any,
523
+ error: Exception | None,
524
+ success: bool,
525
+ task_id: str | None,
526
+ run_id: str,
527
+ **future_kwargs: Any,
528
+ ):
529
+ """Checks that the result type matches the expected node return type."""
530
+ if self.check_output:
531
+ # Replace the isinstance check in your code with check_instance
532
+ if not htypes.check_instance(result, node_return_type):
533
+ raise TypeError(
534
+ f"Node {node_name} returned a result of type {type(result)}, expected {node_return_type}"
535
+ )
536
+
537
+
538
+ SENTINEL_DEFAULT = None # sentinel value -- lazy for now
539
+ INJECTION_ALLOWED = "injection is requested"
540
+
541
+
542
+ def accept_error_sentinels(func: Callable):
543
+ """Tag a function to allow passing in error sentinels.
544
+
545
+ For use with ``GracefulErrorAdapter``. The standard adapter behavior is to skip a node
546
+ when an error sentinel is one of its inputs. This decorator will cause the node to
547
+ run, and place the error sentinel into the appropriate input.
548
+
549
+ Take care to ensure your sentinels are easily distinguishable if you do this - see the
550
+ note in the GracefulErrorAdapater docstring.
551
+
552
+ A use case is any data or computation aggregation step that still wants partial results,
553
+ or considers a failure interesting enough to log or notify.
554
+
555
+ .. code-block:: python
556
+
557
+ SENTINEL = object()
558
+
559
+ ...
560
+
561
+ @accept_error_sentinels
562
+ def results_gathering(result_1: float, result_2: float) -> dict[str, Any]:
563
+ answer = {}
564
+ for name, res in zip(["result 1", "result 2"], [result_1, result_2])
565
+ answer[name] = res
566
+ if res is SENTINEL:
567
+ answer[name] = "Node failure: no result"
568
+ # You may want side-effects for a failure.
569
+ _send_text_that_your_runs_errored()
570
+ return answer
571
+
572
+ ...
573
+ adapter = GracefulErrorAdapter(sentinel_value=SENTINEL)
574
+ ...
575
+
576
+
577
+ """
578
+ # This inline import is not ideal -- we have to do this due to a circular reference
579
+ # See PR + comments here https://github.com/apache/hamilton/pull/1017
580
+ # TODO -- fix the circular reference -- we should be able to depend on this and no pull in lifecycle methods
581
+ from hamilton.function_modifiers.metadata import tag
582
+
583
+ _the_tag = tag(
584
+ **{"hamilton.error_sentinel": INJECTION_ALLOWED}, bypass_reserved_namespaces_=True
585
+ )
586
+ return _the_tag(func)
587
+
588
+
589
+ class GracefulErrorAdapter(NodeExecutionMethod):
590
+ """Gracefully handles errors in a graph's execution. This allows you to proceed despite failure,
591
+ dynamically pruning branches. While it still runs every node, it replaces them with no-ops if any upstream
592
+ required dependencies fail (including optional dependencies).
593
+ """
594
+
595
+ def __init__(
596
+ self,
597
+ error_to_catch: type[Exception],
598
+ sentinel_value: Any = SENTINEL_DEFAULT,
599
+ try_all_parallel: bool = True,
600
+ allow_injection: bool = True,
601
+ ):
602
+ """Initializes the adapter. Allows you to customize the error to catch (which exception
603
+ your graph will throw to indicate failure), as well as the sentinel value to use in place of
604
+ a node's result if it fails (this defaults to ``None``).
605
+
606
+ Note that this is currently only compatible with the dict-based result builder (use at your
607
+ own risk with pandas series, etc...).
608
+
609
+ Be careful using ``None`` as the default -- feel free to replace it with a sentinel value
610
+ of your choice (this could negatively impact your graph's execution if you actually *do* intend
611
+ to use ``None`` return values).
612
+
613
+ You can use this as follows:
614
+
615
+ .. code-block:: python
616
+
617
+ # my_module.py
618
+ # custom exception
619
+ class DoNotProceed(Exception):
620
+ pass
621
+
622
+
623
+ def wont_proceed() -> int:
624
+ raise DoNotProceed()
625
+
626
+
627
+ def will_proceed() -> int:
628
+ return 1
629
+
630
+
631
+ def never_reached(wont_proceed: int) -> int:
632
+ return 1 # this should not be reached
633
+
634
+
635
+ dr = (
636
+ driver.Builder()
637
+ .with_modules(my_module)
638
+ .with_adapters(
639
+ default.GracefulErrorAdapter(
640
+ error_to_catch=DoNotProceed,
641
+ sentinel_value=None
642
+ )
643
+ )
644
+ .build()
645
+ )
646
+ dr.execute(
647
+ ["will_proceed", "never_reached"]
648
+ ) # will return {'will_proceed': 1, 'never_reached': None}
649
+
650
+ Note you can customize the error you want it to fail on and the sentinel value to use in place of a node's result if it fails.
651
+
652
+ For Parallelizable nodes, this adapter will attempt to iterate over the node outputs.
653
+ If an error occurs, the sentinel value is returned and no more iterations over the node
654
+ will occur. Meaning if item (3) fails out of 1,2,3,4,5, 4/5 will not run. If you set
655
+ ``try_all_parallel`` to be False, it only sends one sentinel value into the parallelize sub-dag.
656
+
657
+ Here's an example for parallelizable to demonstrate try_all_parallel:
658
+
659
+ .. code-block:: python
660
+
661
+ # parallel_module.py
662
+ # custom exception
663
+ class DoNotProceed(Exception):
664
+ pass
665
+
666
+
667
+ def start_point() -> Parallelizable[int]:
668
+ for i in range(5):
669
+ if i == 3:
670
+ raise DoNotProceed()
671
+ yield i
672
+
673
+
674
+ def inner(start_point: int) -> int:
675
+ return start_point
676
+
677
+
678
+ def gather(inner: Collect[int]) -> list[int]:
679
+ return inner
680
+
681
+
682
+ dr = (
683
+ driver.Builder()
684
+ .with_modules(parallel_module)
685
+ .with_adapters(
686
+ default.GracefulErrorAdapter(
687
+ error_to_catch=DoNotProceed,
688
+ sentinel_value=None,
689
+ try_all_parallel=True,
690
+ )
691
+ )
692
+ .build()
693
+ )
694
+ dr.execute(["gather"]) # will return {'gather': [0,1,2,None]}
695
+
696
+ dr = (
697
+ driver.Builder()
698
+ .with_modules(parallel_module)
699
+ .with_adapters(
700
+ default.GracefulErrorAdapter(
701
+ error_to_catch=DoNotProceed,
702
+ sentinel_value=None,
703
+ try_all_parallel=False,
704
+ )
705
+ )
706
+ .build()
707
+ )
708
+ dr.execute(["gather"]) # will return {'gather': [None]}
709
+
710
+
711
+ :param error_to_catch: The error to catch
712
+ :param sentinel_value: The sentinel value to use in place of a node's result if it fails
713
+ :param try_all_parallel: Gather parallelizable outputs until a failure, then add a Sentinel.
714
+ :param allow_injection: Flag for considering the ``accept_error_sentinels`` tag. Defaults to True.
715
+ """
716
+ self.error_to_catch = error_to_catch
717
+ self.sentinel_value = sentinel_value
718
+ self.try_all_parallel = try_all_parallel
719
+ self.allow_injection = allow_injection
720
+
721
+ def run_to_execute_node(
722
+ self,
723
+ *,
724
+ node_callable: Any,
725
+ node_kwargs: dict[str, Any],
726
+ is_expand: bool,
727
+ is_collect: bool,
728
+ **future_kwargs: Any,
729
+ ) -> Any:
730
+ # You can use the `is_expand` to see if the node is parallelizable
731
+ # You can use the `is_collect` to see if the node is a collect node
732
+ # TODO -- if it is parallelizable, run the generator in a special way (E.G. loop through the node callable
733
+ # and truncate it/provide sentinels for every failure)
734
+ # TODO -- decide what to do with collect
735
+ """Executes a node. If the node fails, returns the sentinel value."""
736
+ default_return = [self.sentinel_value] if is_expand else self.sentinel_value
737
+ _node_tags = future_kwargs["node_tags"]
738
+ can_inject = _node_tags.get("hamilton.error_sentinel", "") == INJECTION_ALLOWED
739
+ can_inject = can_inject and self.allow_injection
740
+
741
+ if not can_inject:
742
+ for _key, value in node_kwargs.items():
743
+ if type(self.sentinel_value) is type(value):
744
+ if self.sentinel_value == value: # == versus is
745
+ return default_return
746
+ if not is_expand:
747
+ try:
748
+ return node_callable(**node_kwargs)
749
+ except self.error_to_catch:
750
+ return self.sentinel_value
751
+
752
+ if not self.try_all_parallel:
753
+ gen_func = node_callable
754
+ else:
755
+ # Grab the partial-ized function that is a parallelizable.
756
+ gen_func = node_callable.keywords["_callable"]
757
+ try:
758
+ gen = gen_func(**node_kwargs)
759
+ except self.error_to_catch:
760
+ return [self.sentinel_value]
761
+ results: list[Any] = []
762
+ try:
763
+ for _res in gen:
764
+ results.append(_res)
765
+ except self.error_to_catch:
766
+ if self.try_all_parallel:
767
+ results.append(self.sentinel_value)
768
+ else:
769
+ results = [self.sentinel_value]
770
+ return results
771
+
772
+
773
+ class NoEdgeAndInputTypeChecking(EdgeConnectionHook):
774
+ """Permissive adapter to help you skip edge and input type checking.
775
+
776
+ Useful for development.
777
+
778
+ .. code-block:: python
779
+
780
+ from hamilton import driver
781
+ from hamilton.lifecycle import NoEdgeAndInputTypeChecking
782
+
783
+ dr = driver.Builder().with_adapters(NoEdgeAndInputTypeChecking()).build()
784
+
785
+ # now driver is built without any type checking
786
+ dr.execute([...], ...)
787
+
788
+ """
789
+
790
+ def check_edge_types_match(self, type_from: type, type_to: type, **kwargs: Any) -> bool:
791
+ """This is run to check if edge types match. Note that this is an OR functionality
792
+ -- this is run after we do some default checks, so this can only be permissive.
793
+ Return True - always
794
+ """
795
+ return True
796
+
797
+ def validate_input(self, node_type: type, input_value: Any, **kwargs: Any) -> bool:
798
+ """This is run to check if the input is valid for the node type. Note that this is an OR functionality
799
+ -- this is run after we do some default checks, so this can only be permissive.
800
+ Returns True - always.
801
+ """
802
+ return True