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,1475 @@
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 collections
19
+ import dataclasses
20
+ import enum
21
+ import functools
22
+ import json
23
+ import logging
24
+ import pathlib
25
+ import uuid
26
+ from collections.abc import Callable, Collection
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Literal, TypeVar
29
+
30
+ import hamilton.node
31
+ from hamilton import graph_types
32
+ from hamilton.caching import fingerprinting
33
+ from hamilton.caching.cache_key import create_cache_key
34
+ from hamilton.caching.stores.base import (
35
+ MetadataStore,
36
+ ResultRetrievalError,
37
+ ResultStore,
38
+ search_data_adapter_registry,
39
+ )
40
+ from hamilton.caching.stores.file import FileResultStore
41
+ from hamilton.caching.stores.sqlite import SQLiteMetadataStore
42
+ from hamilton.function_modifiers.metadata import cache as cache_decorator
43
+ from hamilton.graph import FunctionGraph
44
+ from hamilton.lifecycle.base import (
45
+ BaseDoNodeExecute,
46
+ BasePostNodeExecute,
47
+ BasePreGraphExecute,
48
+ BasePreNodeExecute,
49
+ )
50
+
51
+ logger = logging.getLogger("hamilton.caching")
52
+
53
+ SENTINEL = object()
54
+ S = TypeVar("S", object, object)
55
+
56
+
57
+ CACHING_BEHAVIORS = Literal["default", "recompute", "disable", "ignore"]
58
+
59
+
60
+ class CachingBehavior(enum.Enum):
61
+ """Behavior applied by the caching adapter
62
+
63
+ DEFAULT:
64
+ Try to retrieve result from cache instead of executing the node. If the node is executed, store the result.
65
+ Compute the result data version and store it too.
66
+
67
+ RECOMPUTE:
68
+ Don't try to retrieve result from cache and always execute the node. Otherwise, behaves as default.
69
+ Useful when nodes are stochastic (e.g., model training) or interact with external
70
+ components (e.g., read from database).
71
+
72
+ DISABLE:
73
+ Node is executed as if the caching feature wasn't enabled.
74
+ It never tries to retrieve results. Results are never stored nor versioned.
75
+ Behaves like IGNORE, but the node remains a dependency for downstream nodes.
76
+ This means downstream cache lookup will likely fail systematically (i.e., if the cache is empty).
77
+
78
+ IGNORE:
79
+ Node is executed as if the caching feature wasn't enable.
80
+ It never tries to retrieve results. Results are never stored nor versioned.
81
+ IGNORE means downstream nodes will ignore this node as a dependency for lookup.
82
+ Ignoring clients and connections can be useful since they shouldn't directly impact the downstream results.
83
+ """
84
+
85
+ DEFAULT = 1
86
+ RECOMPUTE = 2
87
+ DISABLE = 3
88
+ IGNORE = 4
89
+
90
+ @classmethod
91
+ def from_string(cls, string: str) -> "CachingBehavior":
92
+ """Create a caching behavior from a string of the enum value. This is
93
+ leveraged by the ``hamilton.lifecycle.caching.SmartCacheAdapter`` and
94
+ the ``hamilton.function_modifiers.metadata.cache`` decorator.
95
+
96
+ .. code-block::
97
+
98
+ CachingBehavior.from_string("recompute")
99
+
100
+ """
101
+ try:
102
+ return cls[string.upper()]
103
+ except KeyError as e:
104
+ raise KeyError(f"{string} is an invalid `CachingBehavior` value") from e
105
+
106
+
107
+ class NodeRoleInTaskExecution(enum.Enum):
108
+ """Identify the role of a node in task-based execution, in particular when
109
+ ``Parallelizable/Collect`` are used.
110
+
111
+ NOTE This is an internal construct and it will likely change in the future.
112
+
113
+ STANDARD: when task-based execution is not used. All nodes and dependencies are STANDARD.
114
+ EXPAND: node with type ``Parallelizable``. It returns an iterator where individual items need to be handled.
115
+ Dependencies can only be OUTSIDE.
116
+ COLLECT: node with type ``Collect``. It returns an iterable where individual items need to be handled.
117
+ Dependencies can be INSIDE, OUTSIDE, or EXPAND
118
+ OUTSIDE: "outside" of ``Parallelizable/Collect`` paths; handled like STANDARD in most cases.
119
+ Dependencies can be OUTSIDE or COLLECT
120
+ INSIDE: "inside" or "between" a ``Parallelizable/Collect`` nodes.
121
+ Dependencies can be INSIDE, OUTSIDE, or EXPAND.
122
+ """
123
+
124
+ STANDARD = 1
125
+ EXPAND = 2
126
+ COLLECT = 3
127
+ OUTSIDE = 4
128
+ INSIDE = 5
129
+
130
+
131
+ class CachingEventType(enum.Enum):
132
+ """Event types logged by the caching adapter"""
133
+
134
+ GET_DATA_VERSION = "get_data_version"
135
+ SET_DATA_VERSION = "set_data_version"
136
+ GET_CACHE_KEY = "get_cache_key"
137
+ SET_CACHE_KEY = "set_cache_key"
138
+ GET_RESULT = "get_result"
139
+ SET_RESULT = "set_result"
140
+ MISSING_RESULT = "missing_result"
141
+ FAILED_RETRIEVAL = "failed_retrieval"
142
+ EXECUTE_NODE = "execute_node"
143
+ FAILED_EXECUTION = "failed_execution"
144
+ RESOLVE_BEHAVIOR = "resolve_behavior"
145
+ UNHASHABLE_DATA_VERSION = "unhashable_data_version"
146
+ IS_OVERRIDE = "is_override"
147
+ IS_INPUT = "is_input"
148
+ IS_FINAL_VAR = "is_final_var"
149
+ IS_DEFAULT_PARAMETER_VALUE = "is_default_parameter_value"
150
+
151
+
152
+ @dataclasses.dataclass(frozen=True)
153
+ class CachingEvent:
154
+ """Event logged by the caching adapter"""
155
+
156
+ run_id: str
157
+ actor: Literal["adapter", "metadata_store", "result_store"]
158
+ event_type: CachingEventType
159
+ node_name: str
160
+ task_id: str | None = None
161
+ msg: str | None = None
162
+ value: Any | None = None
163
+ timestamp: float = dataclasses.field(
164
+ default_factory=lambda: datetime.now(timezone.utc).timestamp()
165
+ )
166
+
167
+ def __str__(self) -> str:
168
+ """Create a human-readable string format for `print()`"""
169
+
170
+ string = self.node_name
171
+ if self.task_id is not None:
172
+ string += f"::{self.task_id}"
173
+ string += f"::{self.actor}"
174
+ string += f"::{self.event_type.value}"
175
+ if self.msg: # this catches None and empty strings
176
+ string += f"::{self.msg}"
177
+
178
+ return string
179
+
180
+ def as_dict(self):
181
+ return dict(
182
+ run_id=self.run_id,
183
+ timestamp=self.timestamp,
184
+ node_name=self.node_name,
185
+ task_id=self.task_id,
186
+ actor=self.actor,
187
+ event_type=self.event_type.value,
188
+ msg=self.msg,
189
+ value=str(self.value) if self.value else self.value,
190
+ )
191
+
192
+
193
+ # TODO we could add a "driver-level" kwarg to specify the cache format (e.g., parquet, JSON, etc.)
194
+ class HamiltonCacheAdapter(
195
+ BaseDoNodeExecute, BasePreGraphExecute, BasePostNodeExecute, BasePreNodeExecute
196
+ ):
197
+ """Adapter enabling Hamilton's caching feature through ``Builder.with_cache()``
198
+
199
+ .. code-block:: python
200
+
201
+ from hamilton import driver
202
+ import my_dataflow
203
+
204
+ dr = (
205
+ driver.Builder()
206
+ .with_modules(my_dataflow)
207
+ .with_cache()
208
+ .build()
209
+ )
210
+
211
+ # then, you can access the adapter via
212
+ dr.cache
213
+
214
+ """
215
+
216
+ def __init__(
217
+ self,
218
+ path: str | pathlib.Path = ".hamilton_cache",
219
+ metadata_store: MetadataStore | None = None,
220
+ result_store: ResultStore | None = None,
221
+ default: Literal[True] | Collection[str] | None = None,
222
+ recompute: Literal[True] | Collection[str] | None = None,
223
+ ignore: Literal[True] | Collection[str] | None = None,
224
+ disable: Literal[True] | Collection[str] | None = None,
225
+ default_behavior: CACHING_BEHAVIORS | None = None,
226
+ default_loader_behavior: CACHING_BEHAVIORS | None = None,
227
+ default_saver_behavior: CACHING_BEHAVIORS | None = None,
228
+ log_to_file: bool = False,
229
+ **kwargs,
230
+ ):
231
+ """Initialize the cache adapter.
232
+
233
+ :param path: path where the cache metadata and results will be stored
234
+ :param metadata_store: BaseStore handling metadata for the cache adapter
235
+ :param result_store: BaseStore caching dataflow execution results
236
+ :param default: Set caching behavior to DEFAULT for specified node names. If True, apply to all nodes.
237
+ :param recompute: Set caching behavior to RECOMPUTE for specified node names. If True, apply to all nodes.
238
+ :param ignore: Set caching behavior to IGNORE for specified node names. If True, apply to all nodes.
239
+ :param disable: Set caching behavior to DISABLE for specified node names. If True, apply to all nodes.
240
+ :param default_behavior: Set the default caching behavior.
241
+ :param default_loader_behavior: Set the default caching behavior `DataLoader` nodes.
242
+ :param default_saver_behavior: Set the default caching behavior `DataSaver` nodes.
243
+ :param log_to_file: If True, append cache event logs as they happen in JSONL format.
244
+ """
245
+ self._path = path
246
+ self.metadata_store = (
247
+ metadata_store if metadata_store is not None else SQLiteMetadataStore(path=path)
248
+ )
249
+ self.result_store = (
250
+ result_store if result_store is not None else FileResultStore(path=str(path))
251
+ )
252
+ self.log_to_file = log_to_file
253
+
254
+ if sum([default is True, recompute is True, disable is True, ignore is True]) > 1:
255
+ raise ValueError(
256
+ "Can only set one of (`default`, `recompute`, `disable`, `ignore`) to True. Please pass mutually exclusive sets of node names"
257
+ )
258
+ self._default = default
259
+ self._recompute = recompute
260
+ self._disable = disable
261
+ self._ignore = ignore
262
+ self.default_behavior = default_behavior
263
+ self.default_loader_behavior = default_loader_behavior
264
+ self.default_saver_behavior = default_saver_behavior
265
+
266
+ # attributes populated at execution time
267
+ self.run_ids: list[str] = []
268
+ self._fn_graphs: dict[str, FunctionGraph] = {} # {run_id: graph}
269
+ self._data_savers: dict[str, Collection[str]] = {} # {run_id: list[node_name]}
270
+ self._data_loaders: dict[str, Collection[str]] = {} # {run_id: list[node_name]}
271
+ self.behaviors: dict[
272
+ str, dict[str, CachingBehavior]
273
+ ] = {} # {run_id: {node_name: behavior}}
274
+ self.data_versions: dict[
275
+ str, dict[str, str | dict[str, str]]
276
+ ] = {} # {run_id: {node_name: version}} or {run_id: {node_name: {task_id: version}}}
277
+ self.code_versions: dict[str, dict[str, str]] = {} # {run_id: {node_name: version}}
278
+ self.cache_keys: dict[
279
+ str, dict[str, str | dict[str, str]]
280
+ ] = {} # {run_id: {node_name: key}} or {run_id: {node_name: {task_id: key}}}
281
+ self._logs: dict[str, list[CachingEvent]] = {} # {run_id: [logs]}
282
+
283
+ @property
284
+ def last_run_id(self):
285
+ """Run id of the last started run. Not necessarily the last to complete."""
286
+ return self.run_ids[-1]
287
+
288
+ def __getstate__(self) -> dict:
289
+ """Serialization method required for multiprocessing and multithreading
290
+ when using task-based execution with `Parallelizable/Collect`
291
+ """
292
+ state = self.__dict__.copy()
293
+ # store the classes to reinstantiate the same backend in __setstate__
294
+ state["metadata_store_cls"] = self.metadata_store.__class__
295
+ state["metadata_store_init"] = self.metadata_store.__getstate__()
296
+ state["result_store_cls"] = self.result_store.__class__
297
+ state["result_store_init"] = self.result_store.__getstate__()
298
+ del state["metadata_store"]
299
+ del state["result_store"]
300
+ return state
301
+
302
+ def __setstate__(self, state: dict) -> None:
303
+ """Serialization method required for multiprocessing and multithreading
304
+ when using task-based execution with `Parallelizable/Collect`.
305
+
306
+ Create new instances of metadata and result stores to have one connection
307
+ per thread.
308
+ """
309
+ # instantiate the backend from the class, then delete the attribute before
310
+ # setting it on the adapter instance.
311
+ self.metadata_store = state["metadata_store_cls"](**state["metadata_store_init"])
312
+ self.result_store = state["result_store_cls"](**state["result_store_init"])
313
+ del state["metadata_store_cls"]
314
+ del state["result_store_cls"]
315
+ self.__dict__.update(state)
316
+
317
+ def _log_event(
318
+ self,
319
+ run_id: str,
320
+ node_name: str,
321
+ actor: Literal["adapter", "metadata_store", "result_store"],
322
+ event_type: CachingEventType,
323
+ msg: str | None = None,
324
+ value: Any | None = None,
325
+ task_id: str | None = None,
326
+ ) -> None:
327
+ """Add a single event to logs stored in state, keyed by run_id
328
+
329
+ If global log level is set to logging.INFO, only log if event type is GET_RESULT or EXECUTE_NODE;
330
+ If it is set to logging.DEBUG, log all events.
331
+
332
+ If `SmartCacheAdapter.log_to_file` is set to True, log all events to a file in JSONL format.
333
+
334
+ :param node_name: name of the node associated with the event
335
+ :param task_id: optional identifier when using task-based execution. (node_name, task_id) is a primary key
336
+ :param actor: component responsible for the event
337
+ :param event_type: enum specifying what type of event (execute, retrieve, etc.)
338
+ :param msg: additional message to display in the logs (e.g., via terminal)
339
+ :param value: arbitrary value to include (typically a string for data version, code version, cache_key). Must be small and JSON-serializable.
340
+ """
341
+ event = CachingEvent(
342
+ run_id=run_id,
343
+ node_name=node_name,
344
+ task_id=task_id,
345
+ actor=actor,
346
+ event_type=event_type,
347
+ msg=msg,
348
+ value=value,
349
+ )
350
+ if logger.isEnabledFor(logging.DEBUG):
351
+ logger.debug(f"{event.__str__()}")
352
+ elif logger.isEnabledFor(logging.INFO):
353
+ if event.event_type in (CachingEventType.GET_RESULT, CachingEventType.EXECUTE_NODE):
354
+ logger.info(f"{event.__str__()}")
355
+
356
+ self._logs[run_id].append(event)
357
+
358
+ if self.log_to_file:
359
+ log_file_path = pathlib.Path(self.metadata_store._directory, "cache_logs.jsonl")
360
+ json_line = json.dumps(event.as_dict())
361
+ with log_file_path.open("a") as f:
362
+ f.write(json_line + "\n")
363
+
364
+ def _log_by_node_name(
365
+ self, run_id: str, level: Literal["debug", "info"] = "info"
366
+ ) -> dict[str, list[str]]:
367
+ """For a given run, group logs to key them by ``node_name`` or ``(node_name, run_id)`` if applicable."""
368
+ run_logs = collections.defaultdict(list)
369
+ for event in self._logs[run_id]:
370
+ if level == "info":
371
+ if event.event_type not in (
372
+ CachingEventType.GET_RESULT,
373
+ CachingEventType.EXECUTE_NODE,
374
+ ):
375
+ continue
376
+
377
+ key = (event.node_name, event.task_id) if event.task_id else event.node_name
378
+ run_logs[key].append(event)
379
+ return dict(run_logs)
380
+
381
+ def logs(self, run_id: str | None = None, level: Literal["debug", "info"] = "info") -> dict:
382
+ """Execution logs of the cache adapter.
383
+
384
+ :param run_id: If ``None``, return all logged runs. If provided a ``run_id``, group logs by node.
385
+ :param level: If ``"debug"`` log all events. If ``"info"`` only log if result is retrieved or executed.
386
+ :return: a mapping between node/task and a list of logged events
387
+
388
+ .. code-block:: python
389
+
390
+ from hamilton import driver
391
+ import my_dataflow
392
+
393
+ dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
394
+ dr.execute(...)
395
+ dr.execute(...)
396
+
397
+ all_logs = dr.cache.logs()
398
+ # all_logs is a dictionary with run_ids as keys and lists of CachingEvent as values.
399
+ # {
400
+ # run_id_1: [CachingEvent(...), CachingEvent(...)],
401
+ # run_id_2: [CachingEvent(...), CachingEvent(...)],
402
+ # }
403
+
404
+
405
+ run_logs = dr.cache.logs(run_id=dr.last_run_id)
406
+ # run_logs are keyed by ``node_name``
407
+ # {node_name: [CachingEvent(...), CachingEvent(...)], ...}
408
+ # or ``(node_name, task_id)`` if task-based execution is used.
409
+ # {(node_name_1, task_id_1): [CachingEvent(...), CachingEvent(...)], ...}
410
+
411
+ """
412
+ if run_id:
413
+ return self._log_by_node_name(run_id=run_id, level=level)
414
+
415
+ logs = collections.defaultdict(list)
416
+ for run_id, run_logs in self._logs.items():
417
+ for event in run_logs:
418
+ if level == "info" and event.event_type not in (
419
+ CachingEventType.GET_RESULT,
420
+ CachingEventType.EXECUTE_NODE,
421
+ ):
422
+ continue
423
+
424
+ logs[run_id].append(event)
425
+
426
+ return dict(logs)
427
+
428
+ @staticmethod
429
+ def _view_run(
430
+ fn_graph: FunctionGraph,
431
+ logs,
432
+ final_vars: list[str],
433
+ inputs: dict,
434
+ overrides: dict,
435
+ output_file_path: str | None = None,
436
+ ):
437
+ """Create a Hamilton visualization of the execution and the cache hits/misses.
438
+
439
+ This leverages the ``custom_style_function`` feature internally.
440
+ """
441
+ from hamilton.driver import Driver # avoid circular import
442
+
443
+ def _visualization_styling_function(*, node, node_class, logs):
444
+ """Custom style function for the visualization."""
445
+ if any(
446
+ event.event_type == CachingEventType.GET_RESULT for event in logs.get(node.name, [])
447
+ ):
448
+ style = (
449
+ {"penwidth": "3", "color": "#F06449", "fillcolor": "#ffffff"},
450
+ node_class,
451
+ "from cache",
452
+ )
453
+ else:
454
+ style = ({}, node_class, None)
455
+
456
+ return style
457
+
458
+ return Driver._visualize_execution_helper(
459
+ adapter=None,
460
+ bypass_validation=True,
461
+ render_kwargs={},
462
+ output_file_path=output_file_path,
463
+ fn_graph=fn_graph,
464
+ final_vars=final_vars,
465
+ inputs=inputs,
466
+ overrides=overrides,
467
+ custom_style_function=functools.partial(_visualization_styling_function, logs=logs),
468
+ )
469
+
470
+ # TODO make this work directly from the metadata_store too
471
+ # visualization from logs is convenient when debugging someone else's issue
472
+ def view_run(self, run_id: str | None = None, output_file_path: str | None = None):
473
+ """View the dataflow execution, including cache hits/misses.
474
+
475
+ :param run_id: If ``None``, view the last run. If provided a ``run_id``, view that run.
476
+ :param output_file_path: If provided a path, save the visualization to a file.
477
+
478
+ .. code-block:: python
479
+
480
+ from hamilton import driver
481
+ import my_dataflow
482
+
483
+ dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
484
+
485
+ # execute 3 times
486
+ dr.execute(...)
487
+ dr.execute(...)
488
+ dr.execute(...)
489
+
490
+ # view the last run
491
+ dr.cache.view_run()
492
+ # this is equivalent to
493
+ dr.cache.view_run(run_id=dr.last_run_id)
494
+
495
+ # get a specific run id
496
+ run_id = dr.cache.run_ids[1]
497
+ dr.cache.view_run(run_id=run_id)
498
+
499
+ """
500
+ if run_id is None:
501
+ run_id = self.last_run_id
502
+
503
+ fn_graph = self._fn_graphs[run_id]
504
+ logs = self.logs(run_id, level="debug")
505
+
506
+ final_vars = []
507
+ inputs = {}
508
+ overrides = {}
509
+ for key, events in logs.items():
510
+ if isinstance(key, tuple):
511
+ raise ValueError(
512
+ "`.view()` is currently not supported for task-based execution. "
513
+ "Please inspect the logs directly via `.logs(run_id=...)` for debugging."
514
+ )
515
+
516
+ node_name = key
517
+ if any(e.event_type == CachingEventType.IS_FINAL_VAR for e in events):
518
+ final_vars.append(node_name)
519
+
520
+ if any(e.event_type == CachingEventType.IS_INPUT for e in events):
521
+ inputs[node_name] = None # the value doesn't matter, only the key of the dict
522
+ continue
523
+
524
+ elif any(e.event_type == CachingEventType.IS_OVERRIDE for e in events):
525
+ overrides[node_name] = None # the value doesn't matter, only the key of the dict
526
+ continue
527
+
528
+ return self._view_run(
529
+ fn_graph=fn_graph,
530
+ logs=logs,
531
+ final_vars=final_vars,
532
+ inputs=inputs,
533
+ overrides=overrides,
534
+ output_file_path=output_file_path,
535
+ )
536
+
537
+ def _get_node_role(
538
+ self, run_id: str, node_name: str, task_id: str | None
539
+ ) -> NodeRoleInTaskExecution:
540
+ """Determine based on the node name and task_id if a node is part of parallel execution."""
541
+ if task_id is None:
542
+ role = NodeRoleInTaskExecution.STANDARD
543
+ else:
544
+ node_type: hamilton.node.NodeType = self._fn_graphs[run_id].nodes[node_name].node_role
545
+ if node_type == hamilton.node.NodeType.EXPAND:
546
+ role = NodeRoleInTaskExecution.EXPAND
547
+ elif node_type == hamilton.node.NodeType.COLLECT:
548
+ role = NodeRoleInTaskExecution.COLLECT
549
+ elif node_name == task_id:
550
+ role = NodeRoleInTaskExecution.OUTSIDE
551
+ else:
552
+ role = NodeRoleInTaskExecution.INSIDE
553
+
554
+ return role
555
+
556
+ def get_cache_key(self, run_id: str, node_name: str, task_id: str | None = None) -> str | S:
557
+ """Get the ``cache_key`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
558
+
559
+ This method is public-facing and can be used directly to inspect the cache.
560
+
561
+ :param run_id: Id of the Hamilton execution run.
562
+ :param node_name: Name of the node associated with the cache key. ``node_name`` is a unique identifier
563
+ if task-based execution is not used.
564
+ :param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
565
+ is a unique identifier.
566
+ :return: The cache key if it exists, otherwise return a sentinel value.
567
+
568
+ .. code-block:: python
569
+
570
+ from hamilton import driver
571
+ import my_dataflow
572
+
573
+ dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
574
+ dr.execute(...)
575
+
576
+ dr.cache.get_cache_key(run_id=dr.last_run_id, node_name="my_node", task_id=None)
577
+
578
+ """
579
+ node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
580
+
581
+ if node_role == NodeRoleInTaskExecution.INSIDE:
582
+ cache_key = self.cache_keys[run_id].get(node_name, {}).get(task_id, SENTINEL) # type: ignore ; `task_id` can't be None
583
+ else:
584
+ cache_key = self.cache_keys[run_id].get(node_name, SENTINEL)
585
+
586
+ cache_key = cache_key if cache_key is not SENTINEL else None
587
+ self._log_event(
588
+ run_id=run_id,
589
+ node_name=node_name,
590
+ task_id=task_id,
591
+ actor="adapter",
592
+ event_type=CachingEventType.GET_CACHE_KEY,
593
+ msg="hit" if cache_key is not SENTINEL else "miss",
594
+ value=cache_key,
595
+ )
596
+ return cache_key
597
+
598
+ def _set_cache_key(
599
+ self, run_id: str, node_name: str, cache_key: str, task_id: str | None = None
600
+ ) -> None:
601
+ """Set the ``cache_key`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
602
+
603
+ When calling this method, ``cache_key`` must not be ``None``.
604
+ """
605
+ assert cache_key is not None
606
+ node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
607
+ if node_role in (
608
+ NodeRoleInTaskExecution.STANDARD,
609
+ NodeRoleInTaskExecution.OUTSIDE,
610
+ NodeRoleInTaskExecution.EXPAND,
611
+ NodeRoleInTaskExecution.COLLECT,
612
+ ):
613
+ self.cache_keys[run_id][node_name] = cache_key
614
+ elif node_role == NodeRoleInTaskExecution.INSIDE:
615
+ if self.cache_keys[run_id].get(node_name, SENTINEL) is SENTINEL:
616
+ self.cache_keys[run_id][node_name] = {}
617
+ self.cache_keys[run_id][node_name][task_id] = cache_key # type: ignore ; we just initialized the nested dict
618
+ else:
619
+ raise ValueError(
620
+ f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
621
+ )
622
+
623
+ self._log_event(
624
+ run_id=run_id,
625
+ node_name=node_name,
626
+ task_id=task_id,
627
+ actor="adapter",
628
+ event_type=CachingEventType.SET_CACHE_KEY,
629
+ value=cache_key,
630
+ )
631
+
632
+ def _get_memory_data_version(
633
+ self, run_id: str, node_name: str, task_id: str | None = None
634
+ ) -> str | S:
635
+ """Get the ``data_version`` stored in-memory for a specific ``run_id``, ``node_name``, and ``task_id``.
636
+
637
+ The behavior depends on the ``CacheBehavior`` (e.g., RECOMPUTE, IGNORE, DISABLE, DEFAULT) and
638
+ the ``NodeRoleInTaskExecution`` of the node (e.g., STANDARD, OUTSIDE, INSIDE, EXPAND, COLLECT).
639
+
640
+ :param run_id: Id of the Hamilton execution run.
641
+ :param node_name: Name of the node associated with the cache key. ``node_name`` is a unique identifier
642
+ if task-based execution is not used.
643
+ :param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
644
+ is a unique identifier.
645
+ """
646
+ node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
647
+ if node_role in (
648
+ NodeRoleInTaskExecution.STANDARD,
649
+ NodeRoleInTaskExecution.OUTSIDE,
650
+ NodeRoleInTaskExecution.COLLECT,
651
+ ):
652
+ data_version = self.data_versions[run_id].get(node_name, SENTINEL)
653
+ elif node_role == NodeRoleInTaskExecution.EXPAND:
654
+ data_version = SENTINEL
655
+ elif node_role == NodeRoleInTaskExecution.INSIDE:
656
+ tasks_data_versions = self.data_versions[run_id].get(node_name, SENTINEL)
657
+ if isinstance(tasks_data_versions, dict):
658
+ data_version = tasks_data_versions.get(task_id, SENTINEL)
659
+ else:
660
+ data_version = SENTINEL
661
+ else:
662
+ raise ValueError(
663
+ f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
664
+ )
665
+
666
+ self._log_event(
667
+ run_id=run_id,
668
+ node_name=node_name,
669
+ task_id=task_id,
670
+ actor="adapter",
671
+ event_type=CachingEventType.GET_DATA_VERSION,
672
+ msg="hit" if data_version is not SENTINEL else "miss",
673
+ )
674
+ return data_version
675
+
676
+ def _get_stored_data_version(
677
+ self, run_id: str, node_name: str, cache_key: str, task_id: str | None = None
678
+ ) -> str | S:
679
+ """Get the ``data_version`` stored in the metadata store associated with the ``cache_key``.
680
+
681
+ The ``run_id``, ``node_name``, and ``task_id`` are included only for logging purposes.
682
+ """
683
+ data_version = self.metadata_store.get(cache_key=cache_key)
684
+ data_version = SENTINEL if data_version is None else data_version
685
+ self._log_event(
686
+ run_id=run_id,
687
+ node_name=node_name,
688
+ task_id=task_id,
689
+ actor="metadata_store",
690
+ event_type=CachingEventType.GET_DATA_VERSION,
691
+ msg="hit" if data_version is not SENTINEL else "miss",
692
+ )
693
+
694
+ return data_version
695
+
696
+ def get_data_version(
697
+ self,
698
+ run_id: str,
699
+ node_name: str,
700
+ cache_key: str | None = None,
701
+ task_id: str | None = None,
702
+ ) -> str | S:
703
+ """Get the ``data_version`` for a specific ``run_id``, ``node_name``, and ``task_id``.
704
+
705
+ This method is public-facing and can be used directly to inspect the cache. This will check data versions
706
+ stored both in-memory and in the metadata store.
707
+
708
+ :param run_id: Id of the Hamilton execution run.
709
+ :param node_name: Name of the node associated with the data version. ``node_name`` is a unique identifier
710
+ if task-based execution is not used.
711
+ :param task_id: Id of the task when task-based execution is used. Then, the tuple ``(node_name, task_id)``
712
+ is a unique identifier.
713
+ :return: The data version if it exists, otherwise return a sentinel value.
714
+
715
+ ..code-block:: python
716
+
717
+ from hamilton import driver
718
+ import my_dataflow
719
+
720
+ dr = driver.Builder().with_modules(my_dataflow).with_cache().build()
721
+ dr.execute(...)
722
+
723
+ dr.cache.get_data_version(run_id=dr.last_run_id, node_name="my_node", task_id=None)
724
+
725
+ """
726
+
727
+ data_version = self._get_memory_data_version(
728
+ run_id=run_id, node_name=node_name, task_id=task_id
729
+ )
730
+
731
+ if data_version is SENTINEL and cache_key is not None:
732
+ data_version = self._get_stored_data_version(
733
+ run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
734
+ )
735
+
736
+ return data_version
737
+
738
+ def _set_memory_metadata(
739
+ self, run_id: str, node_name: str, data_version: str, task_id: str | None = None
740
+ ) -> None:
741
+ """Set in-memory data_version whether a task_id is specified or not"""
742
+ assert data_version is not None
743
+ node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
744
+ if node_role in (
745
+ NodeRoleInTaskExecution.STANDARD,
746
+ NodeRoleInTaskExecution.OUTSIDE,
747
+ NodeRoleInTaskExecution.COLLECT,
748
+ ):
749
+ self.data_versions[run_id][node_name] = data_version
750
+ elif node_role == NodeRoleInTaskExecution.EXPAND:
751
+ self.data_versions[run_id][node_name] = {}
752
+ elif node_role == NodeRoleInTaskExecution.INSIDE:
753
+ if self.data_versions[run_id].get(node_name, SENTINEL) is SENTINEL:
754
+ self.data_versions[run_id][node_name] = {}
755
+ self.data_versions[run_id][node_name][task_id] = data_version # type: ignore ; we just initialized the nested dict
756
+ else:
757
+ raise ValueError(
758
+ f"Received `{node_role}`. Unhandled `NodeRoleInTaskExecution`, please report this bug."
759
+ )
760
+
761
+ self._log_event(
762
+ run_id=run_id,
763
+ node_name=node_name,
764
+ task_id=task_id,
765
+ actor="adapter",
766
+ event_type=CachingEventType.SET_DATA_VERSION,
767
+ value=data_version,
768
+ )
769
+
770
+ def _set_stored_metadata(
771
+ self,
772
+ run_id: str,
773
+ node_name: str,
774
+ cache_key: str,
775
+ data_version: str,
776
+ task_id: str | None = None,
777
+ ) -> None:
778
+ """Set data_version in the metadata store associated with the cache_key"""
779
+ self.metadata_store.set(
780
+ run_id=run_id,
781
+ node_name=node_name,
782
+ code_version=self.code_versions[run_id][node_name],
783
+ data_version=data_version,
784
+ cache_key=cache_key,
785
+ )
786
+ self._log_event(
787
+ run_id=run_id,
788
+ node_name=node_name,
789
+ task_id=task_id,
790
+ actor="metadata_store",
791
+ event_type=CachingEventType.SET_DATA_VERSION,
792
+ value=data_version,
793
+ )
794
+
795
+ def _version_data(
796
+ self, node_name: str, run_id: str, result: Any, task_id: str | None = None
797
+ ) -> str:
798
+ """Create a unique data version for the result"""
799
+ data_version = fingerprinting.hash_value(result)
800
+ if data_version == fingerprinting.UNHASHABLE:
801
+ self._log_event(
802
+ run_id=run_id,
803
+ node_name=node_name,
804
+ task_id=task_id,
805
+ actor="adapter",
806
+ event_type=CachingEventType.UNHASHABLE_DATA_VERSION,
807
+ msg=f"unhashable type {type(result)}; set CachingBehavior.IGNORE to silence warning",
808
+ value=data_version,
809
+ )
810
+ logger.warning(
811
+ f"Node `{node_name}` has unhashable result of type `{type(result)}`. "
812
+ "Set `CachingBehavior.IGNORE` or register a versioning function to silence warning. "
813
+ "Learn more: https://hamilton.apache.org/concepts/caching/#caching-behavior\n"
814
+ )
815
+ # if the data version is unhashable, we need to set a random suffix to the cache_key
816
+ # to prevent the cache from thinking this value is constant, causing a cache hit.
817
+ data_version = "<unhashable>" + f"_{uuid.uuid4()}"
818
+
819
+ return data_version
820
+
821
+ def version_data(self, result: Any, run_id: str = None) -> str:
822
+ """Create a unique data version for the result
823
+
824
+ This is a user-facing method.
825
+ """
826
+ # stuff the internal function call to not log event
827
+ return self._version_data(result=result, run_id=run_id, node_name=None)
828
+
829
+ def version_code(self, node_name: str, run_id: str | None = None) -> str:
830
+ """Create a unique code version for the source code defining the node"""
831
+ run_id = self.last_run_id if run_id is None else run_id
832
+ node = self._fn_graphs[run_id].nodes[node_name]
833
+ return graph_types.HamiltonNode.from_node(node).version # type: ignore
834
+
835
+ def _execute_node(
836
+ self,
837
+ run_id: str,
838
+ node_name: str,
839
+ node_callable: Callable,
840
+ node_kwargs: dict[str, Any],
841
+ task_id: str | None = None,
842
+ ) -> Any:
843
+ """Simple wrapper that logs the regular execution of a node."""
844
+ logger.debug(node_name)
845
+ result = node_callable(**node_kwargs)
846
+ self._log_event(
847
+ run_id=run_id,
848
+ node_name=node_name,
849
+ task_id=task_id,
850
+ actor="adapter",
851
+ event_type=CachingEventType.EXECUTE_NODE,
852
+ )
853
+ return result
854
+
855
+ @staticmethod
856
+ def _resolve_node_behavior(
857
+ node: hamilton.node.Node,
858
+ default: Collection[str] | None = None,
859
+ disable: Collection[str] | None = None,
860
+ recompute: Collection[str] | None = None,
861
+ ignore: Collection[str] | None = None,
862
+ default_behavior: CACHING_BEHAVIORS = "default",
863
+ default_loader_behavior: CACHING_BEHAVIORS = "default",
864
+ default_saver_behavior: CACHING_BEHAVIORS = "default",
865
+ ) -> CachingBehavior:
866
+ """Determine the cache behavior of a node.
867
+ Behavior specified via the ``Builder`` has precedence over the ``@cache`` decorator.
868
+ Otherwise, set the ``DEFAULT`` behavior.
869
+ If the node is `Parallelizable` enforce the ``RECOMPUTE`` behavior to ensure
870
+ yielded items are versioned individually.
871
+ """
872
+ if node.node_role == hamilton.node.NodeType.EXPAND:
873
+ return CachingBehavior.RECOMPUTE
874
+
875
+ behavior_from_tag = node.tags.get(cache_decorator.BEHAVIOR_KEY, SENTINEL)
876
+ if behavior_from_tag is not SENTINEL:
877
+ behavior_from_tag = CachingBehavior.from_string(behavior_from_tag)
878
+
879
+ behavior_from_driver = SENTINEL
880
+ for behavior, node_set in (
881
+ (CachingBehavior.DEFAULT, default),
882
+ (CachingBehavior.DISABLE, disable),
883
+ (CachingBehavior.RECOMPUTE, recompute),
884
+ (CachingBehavior.IGNORE, ignore),
885
+ ):
886
+ # guard against default None value
887
+ if node_set is None:
888
+ continue
889
+
890
+ if node.name in node_set:
891
+ if behavior_from_driver is not SENTINEL:
892
+ raise ValueError(
893
+ f"Multiple caching behaviors specified by Driver for node: {node.name}"
894
+ )
895
+ behavior_from_driver = behavior
896
+
897
+ if behavior_from_driver is not SENTINEL:
898
+ return behavior_from_driver
899
+ elif behavior_from_tag is not SENTINEL:
900
+ return behavior_from_tag
901
+ elif node.tags.get("hamilton.data_loader"):
902
+ return CachingBehavior.from_string(default_loader_behavior)
903
+ elif node.tags.get("hamilton.data_saver"):
904
+ return CachingBehavior.from_string(default_saver_behavior)
905
+ else:
906
+ return CachingBehavior.from_string(default_behavior)
907
+
908
+ def resolve_behaviors(self, run_id: str) -> dict[str, CachingBehavior]:
909
+ """Resolve the caching behavior for each node based on the ``@cache`` decorator
910
+ and the ``Builder.with_cache()`` parameters for a specific ``run_id``.
911
+
912
+ This is a user-facing method.
913
+
914
+ Behavior specified via ``Builder.with_cache()`` have precedence. If no parameters are specified,
915
+ the ``CachingBehavior.DEFAULT`` is used. If a node is ``Parallelizable`` (i.e., ``@expand``),
916
+ the ``CachingBehavior`` is set to ``CachingBehavior.RECOMPUTE`` to ensure the yielded items
917
+ are versioned individually. Internally, this uses the ``FunctionGraph`` stored for each ``run_id`` and logs
918
+ the resolved caching behavior for each node.
919
+
920
+ :param run_id: Id of the Hamilton execution run.
921
+ :return: A dictionary of ``{node name: caching behavior}``.
922
+ """
923
+ graph = self._fn_graphs[run_id]
924
+
925
+ _default = self._default
926
+ _disable = self._disable
927
+ _recompute = self._recompute
928
+ _ignore = self._ignore
929
+
930
+ if _default is True:
931
+ _default = [n.name for n in graph.get_nodes()]
932
+ elif _disable is True:
933
+ _disable = [n.name for n in graph.get_nodes()]
934
+ elif _recompute is True:
935
+ _recompute = [n.name for n in graph.get_nodes()]
936
+ elif _ignore is True:
937
+ _ignore = [n.name for n in graph.get_nodes()]
938
+
939
+ default_behavior = "default"
940
+ if self.default_behavior is not None:
941
+ default_behavior = self.default_behavior
942
+
943
+ default_loader_behavior = default_behavior
944
+ if self.default_loader_behavior is not None:
945
+ default_loader_behavior = self.default_loader_behavior
946
+
947
+ default_saver_behavior = default_behavior
948
+ if self.default_saver_behavior is not None:
949
+ default_saver_behavior = self.default_saver_behavior
950
+
951
+ behaviors = {}
952
+ for node in graph.get_nodes():
953
+ behavior = HamiltonCacheAdapter._resolve_node_behavior(
954
+ node=node,
955
+ default=_default,
956
+ disable=_disable,
957
+ recompute=_recompute,
958
+ ignore=_ignore,
959
+ default_behavior=default_behavior,
960
+ default_loader_behavior=default_loader_behavior,
961
+ default_saver_behavior=default_saver_behavior,
962
+ )
963
+ behaviors[node.name] = behavior
964
+
965
+ self._log_event(
966
+ run_id=run_id,
967
+ node_name=node.name,
968
+ task_id=None,
969
+ actor="adapter",
970
+ event_type=CachingEventType.RESOLVE_BEHAVIOR,
971
+ value=behavior,
972
+ )
973
+
974
+ # need to handle materializers via a second pass to copy the behavior
975
+ # of their "main node"
976
+ for node in graph.get_nodes():
977
+ if node.tags.get("hamilton.data_loader") is True:
978
+ main_node = node.tags["hamilton.data_loader.node"]
979
+ if main_node == node.name:
980
+ continue
981
+
982
+ # solution for `@dataloader` and `from_`
983
+ if behaviors.get(main_node, None) is not None:
984
+ behaviors[node.name] = behaviors[main_node]
985
+ # this hacky section is required to support @load_from and provide
986
+ # a unified pattern to specify behavior from the module or the driver
987
+ else:
988
+ behaviors[node.name] = HamiltonCacheAdapter._resolve_node_behavior(
989
+ # we create a fake node, only its name matters
990
+ node=hamilton.node.Node(
991
+ name=main_node,
992
+ typ=str,
993
+ callabl=lambda: None,
994
+ tags=node.tags.copy(),
995
+ ),
996
+ default=_default,
997
+ disable=_disable,
998
+ recompute=_recompute,
999
+ ignore=_ignore,
1000
+ default_behavior=default_loader_behavior,
1001
+ )
1002
+
1003
+ self._data_loaders[run_id].append(main_node)
1004
+
1005
+ if node.tags.get("hamilton.data_saver", None) is not None:
1006
+ self._data_savers[run_id].append(node.name)
1007
+
1008
+ return behaviors
1009
+
1010
+ def resolve_code_versions(
1011
+ self,
1012
+ run_id: str,
1013
+ final_vars: list[str] | None = None,
1014
+ inputs: dict[str, Any] | None = None,
1015
+ overrides: dict[str, Any] | None = None,
1016
+ ) -> dict[str, str]:
1017
+ """Resolve the code version for each node for a specific ``run_id``.
1018
+
1019
+ This is a user-facing method.
1020
+
1021
+ If ``final_vars`` is None, all nodes will be versioned. If ``final_vars`` is provided,
1022
+ the ``inputs`` and ``overrides`` are used to determine the execution path and only
1023
+ version the code for these nodes.
1024
+
1025
+ :param run_id: Id of the Hamilton execution run.
1026
+ :param final_vars: Nodes requested for execution.
1027
+ :param inputs: Input node values.
1028
+ :param overrides: Override node values.
1029
+ :return: A dictionary of ``{node name: code version}``.
1030
+ """
1031
+ graph = self._fn_graphs[run_id]
1032
+
1033
+ final_vars = [] if final_vars is None else final_vars
1034
+ inputs = {} if inputs is None else inputs
1035
+ overrides = {} if overrides is None else overrides
1036
+
1037
+ node_selection = graph.get_nodes()
1038
+ if len(final_vars) > 0:
1039
+ all_nodes, user_defined_nodes = graph.get_upstream_nodes(final_vars, inputs, overrides)
1040
+ node_selection = set(all_nodes) - set(user_defined_nodes)
1041
+
1042
+ return {
1043
+ node.name: self.version_code(run_id=run_id, node_name=node.name)
1044
+ for node in node_selection
1045
+ }
1046
+
1047
+ def _process_input(self, run_id: str, node_name: str, value: Any) -> None:
1048
+ """Process input nodes to version data and code.
1049
+
1050
+ To enable caching, input values must be versioned. Since inputs have no associated code,
1051
+ set a constant "code version" ``f"input__{node_name}"`` that uniquely identifies this input.
1052
+ """
1053
+ data_version = self._version_data(node_name=node_name, run_id=run_id, result=value)
1054
+ self.code_versions[run_id][node_name] = f"input__{node_name}"
1055
+ self.data_versions[run_id][node_name] = data_version
1056
+ self._log_event(
1057
+ run_id=run_id,
1058
+ node_name=node_name,
1059
+ task_id=None,
1060
+ actor="adapter",
1061
+ event_type=CachingEventType.IS_INPUT,
1062
+ value=data_version,
1063
+ )
1064
+
1065
+ def _process_override(self, run_id: str, node_name: str, value: Any) -> None:
1066
+ """Process override nodes to version data and code.
1067
+
1068
+ To enable caching, override values must be versioned. As opposed to executed nodes,
1069
+ code and data versions for overrides are not stored because their value is user provided
1070
+ and isn't necessarily tied to the code.
1071
+ """
1072
+ data_version = self._version_data(node_name=node_name, run_id=run_id, result=value)
1073
+ self.data_versions[run_id][node_name] = data_version
1074
+ self._log_event(
1075
+ run_id=run_id,
1076
+ node_name=node_name,
1077
+ task_id=None,
1078
+ actor="adapter",
1079
+ event_type=CachingEventType.IS_OVERRIDE,
1080
+ value=data_version,
1081
+ )
1082
+
1083
+ @staticmethod
1084
+ def _resolve_default_parameter_values(
1085
+ node_: hamilton.node.Node, node_kwargs: dict[str, Any]
1086
+ ) -> dict[str, Any]:
1087
+ """
1088
+ If a node uses the function's default parameter values, they won't be part of the
1089
+ node_kwargs. To ensure a consistent `cache_key` we want to retrieve default parameter
1090
+ values if they're used
1091
+ """
1092
+ resolved_kwargs = node_kwargs.copy()
1093
+ for param_name, param_value in node_.default_parameter_values.items():
1094
+ # if the `param_name` not in `node_kwargs`, it means the node uses the default
1095
+ # parameter value
1096
+ if param_name not in node_kwargs.keys():
1097
+ resolved_kwargs.update(**{param_name: param_value})
1098
+
1099
+ return resolved_kwargs
1100
+
1101
+ def pre_graph_execute(
1102
+ self,
1103
+ *,
1104
+ run_id: str,
1105
+ graph: FunctionGraph,
1106
+ final_vars: list[str],
1107
+ inputs: dict[str, Any],
1108
+ overrides: dict[str, Any],
1109
+ ):
1110
+ """Set up the state of the adapter for a new execution.
1111
+
1112
+ Most attributes need to be keyed by run_id to prevent potential conflicts because
1113
+ the same adapter instance is shared between across all ``Driver.execute()`` calls.
1114
+ """
1115
+ self.run_ids.append(run_id)
1116
+ self.metadata_store.initialize(run_id)
1117
+ self._logs[run_id] = []
1118
+
1119
+ self._fn_graphs[run_id] = graph
1120
+ self.data_versions[run_id] = {}
1121
+ self.cache_keys[run_id] = {}
1122
+ self.code_versions[run_id] = self.resolve_code_versions(
1123
+ run_id=run_id, final_vars=final_vars, inputs=inputs, overrides=overrides
1124
+ )
1125
+ # the empty `._data_loaders` and `._data_savers` need to be instantiated before calling
1126
+ # `self.resolve_behaviors` because it appends to them
1127
+ self._data_loaders[run_id] = []
1128
+ self._data_savers[run_id] = []
1129
+ self.behaviors[run_id] = self.resolve_behaviors(run_id=run_id)
1130
+
1131
+ # final vars are logged to be retrieved by the ``.view_run()`` method
1132
+ for final_var in final_vars:
1133
+ self._log_event(
1134
+ run_id=run_id,
1135
+ node_name=final_var,
1136
+ task_id=None,
1137
+ actor="adapter",
1138
+ event_type=CachingEventType.IS_FINAL_VAR,
1139
+ )
1140
+
1141
+ if inputs:
1142
+ for node_name, value in inputs.items():
1143
+ self._process_input(run_id, node_name, value)
1144
+
1145
+ if overrides:
1146
+ for node_name, value in overrides.items():
1147
+ self._process_override(run_id, node_name, value)
1148
+
1149
+ def pre_node_execute(
1150
+ self,
1151
+ *,
1152
+ run_id: str,
1153
+ node_: hamilton.node.Node,
1154
+ kwargs: dict[str, Any],
1155
+ task_id: str | None = None,
1156
+ **future_kwargs,
1157
+ ):
1158
+ """Before node execution or retrieval, create the cache_key and set it in memory.
1159
+ The cache_key is created based on the node's code version and its dependencies' data versions.
1160
+
1161
+ Collecting ``data_version`` for upstream dependencies requires handling special cases when
1162
+ task-based execution is used:
1163
+ - If the current node is ``COLLECT`` , the dependency annotated with ``Collect[]`` needs to
1164
+ be versioned item by item instead of versioning the full container. This is because the
1165
+ collect order is inconsistent.
1166
+ - If the current node is ``INSIDE`` and the dependency is ``EXPAND``, this means the
1167
+ ``kwargs`` dictionary contains a single item. We need to version this individual item because
1168
+ it will not be available from "inside" the branch for some executors (multiprocessing, multithreading)
1169
+ because they lose access to the data_versions of ``OUTSIDE`` nodes stored in ``self.data_versions``.
1170
+
1171
+ """
1172
+ node_name = node_.name
1173
+ node_kwargs = HamiltonCacheAdapter._resolve_default_parameter_values(node_, kwargs)
1174
+
1175
+ if self.behaviors[run_id][node_name] == CachingBehavior.IGNORE:
1176
+ return
1177
+
1178
+ # won't need the cache_key for either result retrieval or storage
1179
+ if self.behaviors[run_id][node_name] == CachingBehavior.DISABLE:
1180
+ return
1181
+
1182
+ node_role = self._get_node_role(run_id=run_id, node_name=node_name, task_id=task_id)
1183
+ collected_name = (
1184
+ node_.collect_dependency if node_role == NodeRoleInTaskExecution.COLLECT else SENTINEL
1185
+ )
1186
+
1187
+ dependencies_data_versions = {}
1188
+ for dep_name, dep_value in node_kwargs.items():
1189
+ # resolve caching behaviors
1190
+ if self.behaviors[run_id][dep_name] == CachingBehavior.IGNORE:
1191
+ # setting the data_version to "<ignore>" in the cache_key means that
1192
+ # the value of the dependency appears constant to this node
1193
+ dependencies_data_versions[dep_name] = "<ignore>"
1194
+ continue
1195
+ elif self.behaviors[run_id][dep_name] == CachingBehavior.DISABLE:
1196
+ # setting the data_version to "<disable>" with a random suffix in the
1197
+ # cache_key means the current node will be a cache miss and forced to recompute
1198
+ dependencies_data_versions[dep_name] = "<disable>" + f"_{uuid.uuid4()}"
1199
+ continue
1200
+
1201
+ # resolve NodeRoleInTaskExecution
1202
+ if task_id is None:
1203
+ dep_role = NodeRoleInTaskExecution.STANDARD
1204
+ else:
1205
+ # want to check if dependency is an EXPAND node. We must not pass the current `task_id`
1206
+ dep_role = self._get_node_role(
1207
+ run_id=run_id, node_name=dep_name, task_id="<placeholder>"
1208
+ )
1209
+
1210
+ # if dep_role == NodeRoleInTaskExecution.STANDARD:
1211
+
1212
+ if dep_name == collected_name:
1213
+ # the collected value should be hashed based on the items, not the container
1214
+ items_data_versions = [self.version_data(item, run_id=run_id) for item in dep_value]
1215
+ dep_data_version = fingerprinting.hash_value(sorted(items_data_versions))
1216
+
1217
+ elif dep_role == NodeRoleInTaskExecution.EXPAND:
1218
+ # if the dependency is `EXPAND`, the kwarg received is a single item yielded by the iterator
1219
+ # rather than the full iterable. We must version it directly, similar to a top-level input
1220
+ dep_data_version = self.version_data(dep_value, run_id=run_id)
1221
+
1222
+ else:
1223
+ tasks_data_versions = self._get_memory_data_version(
1224
+ run_id=run_id, node_name=dep_name, task_id=None
1225
+ )
1226
+ if tasks_data_versions is SENTINEL:
1227
+ dep_data_version = self.version_data(dep_value, run_id=run_id)
1228
+ elif isinstance(tasks_data_versions, dict):
1229
+ dep_data_version = tasks_data_versions.get(task_id)
1230
+ else:
1231
+ dep_data_version = tasks_data_versions
1232
+
1233
+ if dep_data_version == fingerprinting.UNHASHABLE:
1234
+ # if the data version is unhashable, we need to set a random suffix to the cache_key
1235
+ # to prevent the cache from thinking this value is constant, causing a cache hit.
1236
+ dep_data_version = "<unhashable>" + f"_{uuid.uuid4()}"
1237
+
1238
+ dependencies_data_versions[dep_name] = dep_data_version
1239
+
1240
+ # create cache_key before execution; will be reused during and after execution
1241
+ cache_key = create_cache_key(
1242
+ node_name=node_name,
1243
+ code_version=self.code_versions[run_id][node_name],
1244
+ dependencies_data_versions=dependencies_data_versions,
1245
+ )
1246
+ self._set_cache_key(
1247
+ run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
1248
+ )
1249
+
1250
+ def do_node_execute(
1251
+ self,
1252
+ *,
1253
+ run_id: str,
1254
+ node_: hamilton.node.Node,
1255
+ kwargs: dict[str, Any],
1256
+ task_id: str | None = None,
1257
+ **future_kwargs,
1258
+ ):
1259
+ """Try to retrieve stored result from previous executions or execute the node.
1260
+
1261
+ Use the previously created cache_key to retrieve the data_version from memory or the metadata_store.
1262
+ If data_version is retrieved try to retrieve the result. If it fails, execute the node.
1263
+ Else, execute the node.
1264
+ """
1265
+ node_name = node_.name
1266
+ node_callable = node_.callable
1267
+ node_kwargs = HamiltonCacheAdapter._resolve_default_parameter_values(node_, kwargs)
1268
+
1269
+ if self.behaviors[run_id][node_name] in (
1270
+ CachingBehavior.DISABLE,
1271
+ CachingBehavior.IGNORE,
1272
+ CachingBehavior.RECOMPUTE,
1273
+ ):
1274
+ result = self._execute_node(
1275
+ run_id=run_id,
1276
+ node_name=node_name,
1277
+ node_callable=node_callable,
1278
+ node_kwargs=node_kwargs,
1279
+ task_id=task_id,
1280
+ )
1281
+ if self.behaviors[run_id][node_name] in (
1282
+ CachingBehavior.RECOMPUTE,
1283
+ CachingBehavior.IGNORE,
1284
+ ):
1285
+ cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
1286
+
1287
+ # nodes collected in `._data_loaders` return tuples of (result, metadata)
1288
+ # where metadata often includes a timestamp. To ensure we provide a consistent
1289
+ # `data_version` / hash, we only hash the result part of the materializer return
1290
+ # value and discard the metadata.
1291
+ if node_name in self._data_loaders[run_id] and isinstance(result, tuple):
1292
+ result = result[0]
1293
+
1294
+ data_version = self._version_data(node_name=node_name, run_id=run_id, result=result)
1295
+
1296
+ # nodes collected in `._data_savers` return a dictionary of metadata
1297
+ # this metadata often includes a timestamp, leading to an unstable hash.
1298
+ # we do not version nor store the metadata. This node is executed for its
1299
+ # external effect of saving a file
1300
+ if node_name in self._data_savers[run_id]:
1301
+ data_version = f"{node_name}__metadata"
1302
+
1303
+ self._set_memory_metadata(
1304
+ run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
1305
+ )
1306
+ self._set_stored_metadata(
1307
+ run_id=run_id,
1308
+ node_name=node_name,
1309
+ task_id=task_id,
1310
+ cache_key=cache_key,
1311
+ data_version=data_version,
1312
+ )
1313
+
1314
+ return result
1315
+
1316
+ # cache_key is set in `pre_node_execute`
1317
+ cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
1318
+ # retrieve data version from memory or metadata_store
1319
+ data_version = self.get_data_version(
1320
+ run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
1321
+ )
1322
+
1323
+ need_to_compute_node = False
1324
+ if data_version is SENTINEL:
1325
+ # must execute: data_version not found in memory or in metadata_store
1326
+ need_to_compute_node = True
1327
+ elif data_version == fingerprinting.UNHASHABLE:
1328
+ # must execute: the retrieved data_version is UNHASHABLE, therefore it isn't stored.
1329
+ need_to_compute_node = True
1330
+ elif self.result_store.exists(data_version) is False:
1331
+ # must execute: data_version retrieved, but result store can't find result
1332
+ need_to_compute_node = True
1333
+ self._log_event(
1334
+ run_id=run_id,
1335
+ node_name=node_name,
1336
+ task_id=task_id,
1337
+ actor="result_store",
1338
+ event_type=CachingEventType.MISSING_RESULT,
1339
+ value=data_version,
1340
+ )
1341
+ else:
1342
+ # try to retrieve: data_version retrieve, result store found result
1343
+ try:
1344
+ # successful retrieval: retrieve the result; potentially load using the DataLoader if e.g.,``@cache(format="json")``
1345
+ result = self.result_store.get(data_version=data_version)
1346
+ self._log_event(
1347
+ run_id=run_id,
1348
+ node_name=node_name,
1349
+ task_id=task_id,
1350
+ actor="result_store",
1351
+ event_type=CachingEventType.GET_RESULT,
1352
+ msg="hit",
1353
+ value=data_version,
1354
+ )
1355
+ # set the data_version previously retrieved (could be from memory or store)
1356
+ self._set_memory_metadata(
1357
+ run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
1358
+ )
1359
+ except ResultRetrievalError:
1360
+ # failed retrieval: despite finding the result, probably failed loading data using DataLoader if e.g.,``@cache(format="json")``
1361
+ self.metadata_store.delete(cache_key=cache_key)
1362
+ self.result_store.delete(data_version)
1363
+ need_to_compute_node = True
1364
+
1365
+ if need_to_compute_node is True:
1366
+ result = self._execute_node(
1367
+ run_id=run_id,
1368
+ node_name=node_name,
1369
+ node_callable=node_callable,
1370
+ node_kwargs=node_kwargs,
1371
+ task_id=task_id,
1372
+ )
1373
+
1374
+ # nodes collected in `._data_loaders` return tuples of (result, metadata)
1375
+ # where metadata often includes a timestamp. To ensure we provide a consistent
1376
+ # `data_version` / hash, we only hash the result part of the materializer return
1377
+ # value and discard the metadata.
1378
+ if node_name in self._data_loaders[run_id] and isinstance(result, tuple):
1379
+ result = result[0]
1380
+
1381
+ data_version = self._version_data(node_name=node_name, run_id=run_id, result=result)
1382
+
1383
+ # nodes collected in `._data_savers` return a dictionary of metadata
1384
+ # this metadata often includes a timestamp, leading to an unstable hash.
1385
+ # we do not version nor store the metadata. This node is executed for its
1386
+ # external effect of saving a file
1387
+ if node_name in self._data_savers[run_id]:
1388
+ data_version = f"{node_name}__metadata"
1389
+
1390
+ self._set_memory_metadata(
1391
+ run_id=run_id, node_name=node_name, task_id=task_id, data_version=data_version
1392
+ )
1393
+ self._set_stored_metadata(
1394
+ run_id=run_id,
1395
+ node_name=node_name,
1396
+ task_id=task_id,
1397
+ cache_key=cache_key,
1398
+ data_version=data_version,
1399
+ )
1400
+
1401
+ return result
1402
+
1403
+ def post_node_execute(
1404
+ self,
1405
+ *,
1406
+ run_id: str,
1407
+ node_: hamilton.node.Node,
1408
+ result: str | None,
1409
+ success: bool = True,
1410
+ error: Exception | None = None,
1411
+ task_id: str | None = None,
1412
+ **future_kwargs,
1413
+ ):
1414
+ """Get the cache_key and data_version stored in memory (respectively from
1415
+ pre_node_execute and do_node_execute) and store the result in result_store
1416
+ if it doesn't exist.
1417
+ """
1418
+ node_name = node_.name
1419
+
1420
+ if success is False:
1421
+ self._log_event(
1422
+ run_id=run_id,
1423
+ node_name=node_name,
1424
+ task_id=task_id,
1425
+ actor="adapter",
1426
+ event_type=CachingEventType.FAILED_EXECUTION,
1427
+ msg=f"{error}",
1428
+ )
1429
+ return
1430
+
1431
+ if self.behaviors[run_id][node_name] in (
1432
+ CachingBehavior.DEFAULT,
1433
+ CachingBehavior.RECOMPUTE,
1434
+ CachingBehavior.IGNORE,
1435
+ ):
1436
+ cache_key = self.get_cache_key(run_id=run_id, node_name=node_name, task_id=task_id)
1437
+ data_version = self.get_data_version(
1438
+ run_id=run_id, node_name=node_name, task_id=task_id, cache_key=cache_key
1439
+ )
1440
+ assert data_version is not SENTINEL
1441
+
1442
+ # TODO clean up this logic
1443
+ # check if a materialized file exist before writing results
1444
+ # when using `@cache(format="json")`
1445
+ cache_format = (
1446
+ self._fn_graphs[run_id]
1447
+ .nodes[node_name]
1448
+ .tags.get(cache_decorator.FORMAT_KEY, SENTINEL)
1449
+ )
1450
+ if cache_format is not SENTINEL:
1451
+ saver_cls, loader_cls = search_data_adapter_registry(
1452
+ name=cache_format, type_=type(result)
1453
+ ) # type: ignore
1454
+ materialized_path = self.result_store._materialized_path(data_version, saver_cls)
1455
+ materialized_path_missing = not materialized_path.exists()
1456
+ else:
1457
+ saver_cls, loader_cls = None, None
1458
+ materialized_path_missing = False
1459
+
1460
+ result_missing = not self.result_store.exists(data_version)
1461
+ if result_missing or materialized_path_missing:
1462
+ self.result_store.set(
1463
+ data_version=data_version,
1464
+ result=result,
1465
+ saver_cls=saver_cls,
1466
+ loader_cls=loader_cls,
1467
+ )
1468
+ self._log_event(
1469
+ run_id=run_id,
1470
+ node_name=node_name,
1471
+ task_id=task_id,
1472
+ actor="result_store",
1473
+ event_type=CachingEventType.SET_RESULT,
1474
+ value=data_version,
1475
+ )