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,900 @@
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 inspect
19
+ import logging
20
+ import typing
21
+ from collections.abc import Callable, Collection
22
+ from typing import Any
23
+
24
+ import typing_inspect
25
+
26
+ from hamilton import node
27
+ from hamilton.function_modifiers.base import (
28
+ InvalidDecoratorException,
29
+ NodeCreator,
30
+ NodeInjector,
31
+ SingleNodeNodeTransformer,
32
+ )
33
+ from hamilton.function_modifiers.dependencies import (
34
+ LiteralDependency,
35
+ ParametrizedDependency,
36
+ UpstreamDependency,
37
+ )
38
+ from hamilton.htypes import custom_subclass_check
39
+ from hamilton.io.data_adapters import AdapterCommon, DataLoader, DataSaver
40
+ from hamilton.node import DependencyType
41
+ from hamilton.registry import LOADER_REGISTRY, SAVER_REGISTRY
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class AdapterFactory:
47
+ """Factory for data loaders. This handles the fact that we pass in source(...) and value(...)
48
+ parameters to the data loaders."""
49
+
50
+ def __init__(self, adapter_cls: type[AdapterCommon], **kwargs: ParametrizedDependency):
51
+ """Initializes an adapter factory. This takes in parameterized dependencies
52
+ and stores them for later resolution.
53
+
54
+ Note that this is not strictly necessary -- we could easily put this in the
55
+ decorator, but I wanted to separate out/consolidate the logic between data savers and data
56
+ loaders.
57
+
58
+ :param adapter_cls: Class of the loader to create.
59
+ :param kwargs: Keyword arguments to pass to the loader, as parameterized dependencies.
60
+ """
61
+ self.adapter_cls = adapter_cls
62
+ self.kwargs = kwargs
63
+ self.validate()
64
+
65
+ def validate(self):
66
+ """Validates that the loader class has the required arguments, and that
67
+ the arguments passed in are valid.
68
+
69
+ :raises InvalidDecoratorException: If the arguments are invalid.
70
+ """
71
+ required_args = self.adapter_cls.get_required_arguments()
72
+ optional_args = self.adapter_cls.get_optional_arguments()
73
+ missing_params = set(required_args.keys()) - set(self.kwargs.keys())
74
+ extra_params = (
75
+ set(self.kwargs.keys()) - set(required_args.keys()) - set(optional_args.keys())
76
+ )
77
+ if len(missing_params) > 0:
78
+ raise InvalidDecoratorException(
79
+ f"Missing required parameters for adapter : {self.adapter_cls}: {missing_params}. "
80
+ f"Required parameters/types are: {required_args}. Optional parameters/types are: "
81
+ f"{optional_args}. "
82
+ )
83
+ if len(extra_params) > 0:
84
+ available_args = {**required_args, **optional_args}.keys()
85
+ raise InvalidDecoratorException(
86
+ f"Extra parameters for loader: {self.adapter_cls} {extra_params}. Choices for parameters are: "
87
+ f"{available_args}."
88
+ )
89
+
90
+ def create_loader(self, **resolved_kwargs: Any) -> DataLoader:
91
+ if not self.adapter_cls.can_load():
92
+ raise InvalidDecoratorException(f"Adapter {self.adapter_cls} cannot load data.")
93
+ return self.adapter_cls(**resolved_kwargs)
94
+
95
+ def create_saver(self, **resolved_kwargs: Any) -> DataSaver:
96
+ if not self.adapter_cls.can_save():
97
+ raise InvalidDecoratorException(f"Adapter {self.adapter_cls} cannot save data.")
98
+ return self.adapter_cls(**resolved_kwargs)
99
+
100
+
101
+ def resolve_kwargs(kwargs: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]:
102
+ """Resolves kwargs to a list of dependencies, and a dictionary of name
103
+ to resolved literal values.
104
+
105
+ :return: A tuple of the dependencies, and the resolved literal kwargs.
106
+ """
107
+ dependencies = {}
108
+ resolved_kwargs = {}
109
+ for name, dependency in kwargs.items():
110
+ if isinstance(dependency, UpstreamDependency):
111
+ dependencies[name] = dependency.source
112
+ elif isinstance(dependency, LiteralDependency):
113
+ resolved_kwargs[name] = dependency.value
114
+ else:
115
+ resolved_kwargs[name] = dependency
116
+ return dependencies, resolved_kwargs
117
+
118
+
119
+ def resolve_adapter_class(
120
+ type_: type[type], loader_classes: list[type[AdapterCommon]]
121
+ ) -> type[AdapterCommon] | None:
122
+ """Resolves the loader class for a function. This will return the most recently
123
+ registered loader class that applies to the injection type, hence the reversed order.
124
+
125
+ :param fn: Function to inject the loaded data into.
126
+ :return: The loader class to use.
127
+ """
128
+ applicable_adapters: list[type[AdapterCommon]] = []
129
+ loaders_with_any = []
130
+ for loader_cls in reversed(loader_classes):
131
+ # We do this here, rather than in applies_to, as its a bit of a special case
132
+ # Any should always get last priority, as its very non-specific and should be able to
133
+ # This is partially for backwards compatibility as we haven't always supported these -- including it now
134
+ # would potentially use the wrong loader for production cases
135
+ if Any in loader_cls.applicable_types():
136
+ loaders_with_any.append(loader_cls)
137
+ if loader_cls.applies_to(type_):
138
+ applicable_adapters.append(loader_cls)
139
+ if len(applicable_adapters) > 0:
140
+ if len(applicable_adapters) > 1:
141
+ logger.warning(
142
+ f"More than one applicable adapter detected for {type_}. "
143
+ f"Using the last one registered {applicable_adapters[0]}."
144
+ )
145
+ return applicable_adapters[0]
146
+ if loaders_with_any:
147
+ return loaders_with_any[0]
148
+ return None
149
+
150
+
151
+ class LoadFromDecorator(NodeInjector):
152
+ def __init__(
153
+ self,
154
+ loader_classes: typing.Sequence[type[DataLoader]],
155
+ inject_=None,
156
+ **kwargs: ParametrizedDependency,
157
+ ):
158
+ """Instantiates a load_from decorator. This decorator will load from a data source,
159
+ and
160
+
161
+ :param inject: The name of the parameter to inject the data into.
162
+ :param loader_cls: The data loader class to use.
163
+ :param kwargs: The arguments to pass to the data loader.
164
+ """
165
+ self.loader_classes = loader_classes
166
+ self.kwargs = kwargs
167
+ self.inject = inject_
168
+
169
+ def _select_param_to_inject(self, params: list[str], fn: Callable) -> str:
170
+ """Chooses a parameter to inject, given the parameters available. If self.inject is None
171
+ (meaning we inject the only parameter), then that's the one. If it is not None, then
172
+ we need to ensure it is one of the available parameters, in which case we choose it.
173
+ """
174
+ if self.inject is None:
175
+ if len(params) == 1:
176
+ return params[0]
177
+ raise InvalidDecoratorException(
178
+ f"If the nodes produced by {fn.__qualname__} require multiple inputs, "
179
+ f"you must pass `inject_` to the load_from decorator for "
180
+ f"function: {fn.__qualname__}"
181
+ )
182
+ if self.inject not in params:
183
+ raise InvalidDecoratorException(
184
+ f"Parameter {self.inject} not required by nodes produced by {fn.__qualname__}"
185
+ )
186
+ return self.inject
187
+
188
+ def get_loader_nodes(
189
+ self, inject_parameter: str, load_type: type[type], namespace: str = None
190
+ ) -> list[node.Node]:
191
+ loader_cls = resolve_adapter_class(
192
+ load_type,
193
+ self.loader_classes,
194
+ )
195
+ if loader_cls is None:
196
+ raise InvalidDecoratorException(
197
+ f"Could not resolve loader for type: {load_type} given possibilities: {self.loader_classes} "
198
+ )
199
+ loader_factory = AdapterFactory(loader_cls, **self.kwargs)
200
+ # dependencies is a map from param name -> source name
201
+ # we use this to pass the right arguments to the loader.
202
+ dependencies, resolved_kwargs = resolve_kwargs(self.kwargs)
203
+ # we need to invert the dependencies so that we can pass
204
+ # the right argument to the loader
205
+ dependencies_inverted = {v: k for k, v in dependencies.items()}
206
+
207
+ def load_data(
208
+ __loader_factory: AdapterFactory = loader_factory,
209
+ __load_type: type[type] = load_type,
210
+ __resolved_kwargs=resolved_kwargs,
211
+ __dependencies=dependencies_inverted,
212
+ __optional_params=loader_cls.get_optional_arguments(), # noqa: B008
213
+ **input_kwargs: Any,
214
+ ) -> tuple[load_type, dict[str, Any]]:
215
+ input_args_with_fixed_dependencies = {
216
+ __dependencies.get(key, key): value for key, value in input_kwargs.items()
217
+ }
218
+ kwargs = {**__resolved_kwargs, **input_args_with_fixed_dependencies}
219
+ data_loader = __loader_factory.create_loader(**kwargs)
220
+ return data_loader.load_data(load_type)
221
+
222
+ def get_input_type_key(key: str) -> str:
223
+ return key if key not in dependencies else dependencies[key]
224
+
225
+ input_types = {
226
+ get_input_type_key(key): (type_, DependencyType.REQUIRED)
227
+ for key, type_ in loader_cls.get_required_arguments().items()
228
+ }
229
+ input_types.update(
230
+ {
231
+ dependencies[key]: (type_, DependencyType.OPTIONAL)
232
+ for key, type_ in loader_cls.get_optional_arguments().items()
233
+ if key in dependencies
234
+ }
235
+ )
236
+ # Take out all the resolved kwargs, as they are not dependencies, and will be filled out
237
+ # later
238
+ input_types = {
239
+ key: value for key, value in input_types.items() if key not in resolved_kwargs
240
+ }
241
+
242
+ # the loader node is the node that loads the data from the data source.
243
+ loader_node = node.Node(
244
+ name=f"{inject_parameter}",
245
+ callabl=load_data,
246
+ typ=tuple[dict[str, Any], load_type],
247
+ input_types=input_types,
248
+ tags={
249
+ "hamilton.data_loader": True,
250
+ "hamilton.data_loader.has_metadata": True,
251
+ "hamilton.data_loader.source": f"{loader_cls.name()}",
252
+ "hamilton.data_loader.classname": f"{loader_cls.__qualname__}",
253
+ "hamilton.data_loader.node": inject_parameter,
254
+ },
255
+ namespace=(
256
+ (namespace, "load_data") if namespace else ("load_data",)
257
+ ), # We want no namespace in this case
258
+ )
259
+
260
+ # the filter node is the node that takes the data from the data source, filters out
261
+ # metadata, and feeds it in as a parameter. Note that this must have the same name as the
262
+ # inject parameter
263
+
264
+ def filter_function(_inject_parameter=inject_parameter, **kwargs):
265
+ return kwargs[loader_node.name][0] # filter it out
266
+
267
+ filter_node = node.Node(
268
+ name=f"{inject_parameter}",
269
+ callabl=filter_function,
270
+ typ=load_type,
271
+ input_types={loader_node.name: loader_node.type},
272
+ tags={
273
+ "hamilton.data_loader": True,
274
+ "hamilton.data_loader.has_metadata": False,
275
+ "hamilton.data_loader.source": f"{loader_cls.name()}",
276
+ "hamilton.data_loader.classname": f"{loader_cls.__qualname__}",
277
+ "hamilton.data_loader.node": inject_parameter,
278
+ },
279
+ # This is a little sloppy -- we need to figure out the best way to handle namespacing
280
+ # In reality we will likely be changing the API -- using the logging construct so we don't have
281
+ # to have this weird DAG shape. For now, this solves the problem, and this is an internal component of the API
282
+ # so we're good to go
283
+ namespace=((namespace, "select_data") if namespace else ()), # We want no namespace
284
+ )
285
+ return [loader_node, filter_node]
286
+
287
+ def inject_nodes(
288
+ self, params: dict[str, type[type]], config: dict[str, Any], fn: Callable
289
+ ) -> tuple[Collection[node.Node], dict[str, str]]:
290
+ """Generates two nodes:
291
+ 1. A node that loads the data from the data source, and returns that + metadata
292
+ 2. A node that takes the data from the data source, injects it into, and runs, the function.
293
+
294
+ :param fn: The function to decorate.
295
+ :param config: The configuration to use.
296
+ :return: The resolved nodes
297
+ """
298
+ inject_parameter = self._select_param_to_inject(list(params.keys()), fn)
299
+ load_type = params[inject_parameter]
300
+ try:
301
+ loader_node, filter_node = self.get_loader_nodes(
302
+ inject_parameter, load_type, fn.__name__
303
+ )
304
+ except InvalidDecoratorException as e:
305
+ raise InvalidDecoratorException(f"Cannot find : {fn.__qualname__}: {e}") from e
306
+
307
+ return [loader_node, filter_node], {inject_parameter: filter_node.name}
308
+
309
+ def _get_inject_parameter_from_function(self, fn: Callable) -> tuple[str, type[type]]:
310
+ """Gets the name of the parameter to inject the data into.
311
+
312
+ :param fn: The function to decorate.
313
+ :return: The name of the parameter to inject the data into.
314
+ """
315
+ sig = inspect.signature(fn)
316
+ if self.inject is None:
317
+ if len(sig.parameters) == 0:
318
+ raise InvalidDecoratorException(
319
+ f"The function: {fn.__qualname__} has no parameters. "
320
+ f"The data loader functionality injects the loaded data "
321
+ f"into the function, so you must have at least one parameter."
322
+ )
323
+ if len(sig.parameters) != 1:
324
+ raise InvalidDecoratorException(
325
+ f"If you have multiple parameters in the signature, "
326
+ f"you must pass `inject_` to the load_from decorator for "
327
+ f"function: {fn.__qualname__}"
328
+ )
329
+ inject = list(sig.parameters.keys())[0]
330
+
331
+ else:
332
+ if self.inject not in sig.parameters:
333
+ raise InvalidDecoratorException(
334
+ f"Invalid inject parameter: {self.inject} for fn: {fn.__qualname__}"
335
+ )
336
+ inject = self.inject
337
+ return inject, typing.get_type_hints(fn)[inject]
338
+
339
+ def validate(self, fn: Callable):
340
+ """Note that we actually do something a little clever here. While the decorator operates
341
+ on the nodes in the subdag, we actually validate it against the function. This ensures that
342
+ we're not doing a lot of weird dynamic things that inject parameters that aren't in the
343
+ function.
344
+
345
+ :param fn: Function that produces the data loader.
346
+ """
347
+
348
+ inject_parameter, type_ = self._get_inject_parameter_from_function(fn)
349
+ cls = resolve_adapter_class(type_, self.loader_classes)
350
+ if cls is None:
351
+ raise InvalidDecoratorException(
352
+ f"No loader class found for type: {type_} specified by "
353
+ f"parameter: {inject_parameter} in function: {fn.__qualname__}"
354
+ )
355
+ loader_factory = AdapterFactory(cls, **self.kwargs)
356
+ loader_factory.validate()
357
+
358
+
359
+ class load_from__meta__(type):
360
+ """Metaclass for the load_from decorator. This is specifically to allow class access method.
361
+ Note that there *is* another way to do this -- we couold add attributes dynamically on the
362
+ class in registry, or make it a function that just proxies to the decorator. We can always
363
+ change this up, but this felt like a pretty clean way of doing it, where we can decouple the
364
+ registry from the decorator class.
365
+ """
366
+
367
+ def __getattr__(cls, item: str):
368
+ if item in LOADER_REGISTRY:
369
+ return load_from.decorator_factory(LOADER_REGISTRY[item])
370
+ try:
371
+ return super().__getattribute__(item)
372
+ except AttributeError as e:
373
+ raise AttributeError(
374
+ f"No loader named: {item} available for {cls.__name__}. "
375
+ f"Available loaders are: {LOADER_REGISTRY.keys()}. "
376
+ f"If you've gotten to this point, you either (1) spelled the "
377
+ f"loader name wrong, (2) are trying to use a loader that does"
378
+ f"not exist (yet). For a list of available loaders, see: "
379
+ f"https://hamilton.readthedocs.io/reference/io/available-data-adapters/#data"
380
+ f"-loaders "
381
+ ) from e
382
+
383
+
384
+ class load_from(metaclass=load_from__meta__):
385
+ """Decorator to inject externally loaded data into a function. Ideally, anything that is not
386
+ a pure transform should either call this, or accept inputs from an external location.
387
+
388
+ This decorator functions by "injecting" a parameter into the function. For example,
389
+ the following code will load the json file, and inject it into the function as the parameter
390
+ `input_data`. Note that the path for the JSON file comes from another node called
391
+ raw_data_path (which could also be passed in as an external input).
392
+
393
+ .. code-block:: python
394
+
395
+ @load_from.json(path=source("raw_data_path"))
396
+ def raw_data(input_data: dict) -> dict:
397
+ return input_data
398
+
399
+ The decorator can also be used with `value` to inject a constant value into the loader.
400
+ In the following case, we use the literal value "some/path.json" as the path to the JSON file.
401
+
402
+ .. code-block:: python
403
+
404
+ @load_from.json(path=value("some/path.json"))
405
+ def raw_data(input_data: dict) -> dict:
406
+ return input_data
407
+
408
+ Note that, if neither `source` nor `value` is specified, the value will be passed in as a
409
+ literal value.
410
+
411
+ .. code-block:: python
412
+
413
+ @load_from.json(path="some/path.json")
414
+ def raw_data(input_data: dict) -> dict:
415
+ return input_data
416
+
417
+ You can also utilize the `inject_` parameter in the loader if you want to inject the data
418
+ into a specific param. For example, the following code will load the json file, and inject it
419
+ into the function as the parameter `data`.
420
+
421
+ .. code-block:: python
422
+
423
+ @load_from.json(path=source("raw_data_path"), inject_="data")
424
+ def raw_data(data: dict, valid_keys: List[str]) -> dict:
425
+ return [item for item in data if item in valid_keys]
426
+
427
+ You can also utilize multiple data loaders with separate `inject_` parameters to
428
+ load from multiple files.
429
+ data loaders to a single function:
430
+
431
+ .. code-block:: python
432
+
433
+ @load_from.json(path=source("raw_data_path"), inject_="data")
434
+ @load_from.json(path=source("raw_data_path2"), inject_="data2")
435
+ def raw_data(data: dict, data2: dict) -> dict:
436
+ return [item for item in data if item in data2]
437
+
438
+
439
+ This is a highly pluggable functionality -- here's the basics of how it works:
440
+
441
+ 1. Every "key" (json above, but others include csv, literal, file, pickle, etc...) corresponds
442
+ to a set of loader classes. For example, the json key corresponds to the JSONLoader class in
443
+ default_data_loaders. They implement the classmethod `name`. Once they are registered with the
444
+ central registry they pick
445
+
446
+ 2. Every data loader class (which are all dataclasses) implements the `load_targets` method,
447
+ which returns a list of types it can load to. For example, the JSONLoader class can load data
448
+ of type `dict`. Note that the set of potential loading candidate classes are evaluated in
449
+ reverse order, so the most recently registered loader class is the one that is used. That
450
+ way, you can register custom ones.
451
+
452
+ 3. The loader class is instantiated with the kwargs passed to the decorator. For example, the
453
+ JSONLoader class takes a `path` kwarg, which is the path to the JSON file.
454
+
455
+ 4. The decorator then creates a node that loads the data, and modifies the node that runs the
456
+ function to accept that. It also returns metadata (customizable at the loader-class-level) to
457
+ enable debugging after the fact. This is unstructured, but can be used down the line to describe
458
+ any metadata to help debug.
459
+
460
+ The "core" hamilton library contains a few basic data loaders that can be implemented within
461
+ the confines of python's standard library. pandas_extensions contains a few more that require
462
+ pandas to be installed.
463
+
464
+ Note that these can have `default` arguments, specified by defaults in the dataclass fields.
465
+ For the full set of "keys" and "types" (e.g. load_from.json, etc...), look for all classes
466
+ that inherit from `DataLoader` in the hamilton library. We plan to improve documentation shortly
467
+ to make this discoverable.
468
+ """
469
+
470
+ def __call__(self, *args, **kwargs):
471
+ return LoadFromDecorator(*args, **kwargs)
472
+
473
+ @classmethod
474
+ def decorator_factory(
475
+ cls, loaders: typing.Sequence[type[DataLoader]]
476
+ ) -> Callable[..., LoadFromDecorator]:
477
+ """Effectively a partial function for the load_from decorator. Broken into its own (
478
+ rather than using functools.partial) as it is a little clearer to parse.
479
+
480
+ :param loaders: Options of data loader classes to use
481
+ :return: The data loader decorator.
482
+ """
483
+
484
+ def create_decorator(
485
+ __loaders=tuple(loaders), inject_=None, **kwargs: ParametrizedDependency
486
+ ):
487
+ return LoadFromDecorator(__loaders, inject_=inject_, **kwargs)
488
+
489
+ return create_decorator
490
+
491
+
492
+ class save_to__meta__(type):
493
+ """See note on load_from__meta__ for details on how this works."""
494
+
495
+ def __getattr__(cls, item: str):
496
+ if item in SAVER_REGISTRY:
497
+ return save_to.decorator_factory(SAVER_REGISTRY[item])
498
+ try:
499
+ return super().__getattribute__(item)
500
+ except AttributeError as e:
501
+ raise AttributeError(
502
+ f"No saver named: {item} available for {cls.__name__}. "
503
+ f"Available data savers are: {list(SAVER_REGISTRY.keys())}. "
504
+ "If you've gotten to this point, you either (1) spelled the "
505
+ "loader name wrong, (2) are trying to use a saver that does"
506
+ "not exist (yet). For a list of available savers, see "
507
+ "https://hamilton.apache.org/reference/io/available-data-adapters/"
508
+ ) from e
509
+
510
+
511
+ class SaveToDecorator(SingleNodeNodeTransformer):
512
+ def __init__(
513
+ self,
514
+ saver_classes_: typing.Sequence[type[DataSaver]],
515
+ output_name_: str = None,
516
+ target_: str = None,
517
+ **kwargs: ParametrizedDependency,
518
+ ):
519
+ super(SaveToDecorator, self).__init__()
520
+ self.artifact_name = output_name_
521
+ self.saver_classes = saver_classes_
522
+ self.kwargs = kwargs
523
+ self.target = target_
524
+
525
+ def create_saver_node(
526
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
527
+ ) -> node.Node:
528
+ artifact_name = self.artifact_name
529
+ artifact_namespace = ()
530
+ node_to_save = node_.name if self.target is None else self.target
531
+
532
+ if artifact_name is None:
533
+ artifact_name = node_to_save
534
+ artifact_namespace = ("save",)
535
+
536
+ type_ = node_.type
537
+ saver_cls = resolve_adapter_class(
538
+ type_,
539
+ self.saver_classes,
540
+ )
541
+ if saver_cls is None:
542
+ raise InvalidDecoratorException(
543
+ f"No saver class found for type: {type_} specified by "
544
+ f"output type: {type_} in node: {node_to_save} generated by "
545
+ f"function: {fn.__qualname__}."
546
+ )
547
+
548
+ adapter_factory = AdapterFactory(saver_cls, **self.kwargs)
549
+ dependencies, resolved_kwargs = resolve_kwargs(self.kwargs)
550
+ dependencies_inverted = {v: k for k, v in dependencies.items()}
551
+
552
+ def save_data(
553
+ __adapter_factory=adapter_factory,
554
+ __dependencies=dependencies_inverted,
555
+ __resolved_kwargs=resolved_kwargs,
556
+ __data_node_name=node_to_save,
557
+ **input_kwargs,
558
+ ) -> dict[str, Any]:
559
+ input_args_with_fixed_dependencies = {
560
+ __dependencies.get(key, key): value for key, value in input_kwargs.items()
561
+ }
562
+ kwargs = {**__resolved_kwargs, **input_args_with_fixed_dependencies}
563
+ data_to_save = kwargs[__data_node_name]
564
+ kwargs = {k: v for k, v in kwargs.items() if k != __data_node_name}
565
+ data_saver = __adapter_factory.create_saver(**kwargs)
566
+ return data_saver.save_data(data_to_save)
567
+
568
+ def get_input_type_key(key: str) -> str:
569
+ return key if key not in dependencies else dependencies[key]
570
+
571
+ input_types = {
572
+ get_input_type_key(key): (type_, DependencyType.REQUIRED)
573
+ for key, type_ in saver_cls.get_required_arguments().items()
574
+ }
575
+ input_types.update(
576
+ {
577
+ dependencies[key]: (type_, DependencyType.OPTIONAL)
578
+ for key, type_ in saver_cls.get_optional_arguments().items()
579
+ if key in dependencies
580
+ }
581
+ )
582
+ # Take out all the resolved kwargs, as they are not dependencies, and will be filled out
583
+ # later
584
+ input_types = {
585
+ key: value for key, value in input_types.items() if key not in resolved_kwargs
586
+ }
587
+ input_types[node_to_save] = (node_.type, DependencyType.REQUIRED)
588
+ save_node = node.Node(
589
+ name=artifact_name,
590
+ callabl=save_data,
591
+ typ=dict[str, Any],
592
+ input_types=input_types,
593
+ namespace=artifact_namespace,
594
+ tags={
595
+ "hamilton.data_saver": True,
596
+ "hamilton.data_saver.sink": f"{saver_cls.name()}",
597
+ "hamilton.data_saver.classname": f"{saver_cls.__qualname__}",
598
+ },
599
+ )
600
+ return save_node
601
+
602
+ def transform_node(
603
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
604
+ ) -> Collection[node.Node]:
605
+ """Transforms the node to a data saver.
606
+
607
+ :param node_: Node to transform
608
+ :param config: Config to use
609
+ :param fn: Original function that generated the node
610
+ :return: The transformed node
611
+ """
612
+
613
+ return [
614
+ self.create_saver_node(node_, config, fn),
615
+ node_,
616
+ ]
617
+
618
+ def validate(self, fn: Callable):
619
+ pass
620
+
621
+
622
+ class save_to(metaclass=save_to__meta__):
623
+ """Decorator that outputs data to some external source. You can think
624
+ about this as the inverse of load_from.
625
+
626
+ This decorates a function, takes the final node produced by that function
627
+ and then appends an additional node that saves the output of that function.
628
+
629
+ As the load_from decorator does, this decorator can be referred to in a
630
+ dynamic way. For instance, @save_to.json will save the output of the function
631
+ to a json file. Note that this means that the output of the function must
632
+ be a dictionary (or subclass thereof), otherwise the decorator will fail.
633
+
634
+ Looking at the json example:
635
+
636
+ .. code-block:: python
637
+
638
+ @save_to.json(path=source("raw_data_path"), output_name_="data_save_output")
639
+ def final_output(data: dict, valid_keys: List[str]) -> dict:
640
+ return [item for item in data if item in valid_keys]
641
+
642
+ This adds a final node to the DAG with the name "data_save_output" that
643
+ accepts the output of the function "final_output" and saves it to a json.
644
+ In this case, the JSONSaver accepts a `path` parameter, which is provided
645
+ by the upstream node (or input) named "raw_data_path". The `output_name_`
646
+ parameter then says how to refer to the output of this node in the DAG.
647
+
648
+ If you called this with the driver:
649
+
650
+ .. code-block:: python
651
+
652
+ dr = driver.Driver(my_module)
653
+ output = dr.execute(["final_output"], {"raw_data_path": "/path/my_data.json"})
654
+
655
+ You would *just* get the final result, and nothing would be saved.
656
+
657
+ If you called this with the driver:
658
+
659
+ .. code-block:: python
660
+
661
+ dr = driver.Driver(my_module)
662
+ output = dr.execute(["data_save_output"], {"raw_data_path": "/path/my_data.json"})
663
+
664
+ You would get a dictionary of metadata (about the saving output), and the final result would
665
+ be saved to a path.
666
+
667
+ Note that you can also hardcode the path, rather than using a dependency:
668
+
669
+ .. code-block:: python
670
+
671
+ @save_to.json(path=value("/path/my_data.json"), output_name_="data_save_output")
672
+ def final_output(data: dict, valid_keys: List[str]) -> dict:
673
+ return [item for item in data if item in valid_keys]
674
+
675
+ Note that, like the loader function, you can use literal values as kwargs and they'll get interpreted as values.
676
+ If you needs savers, you should also look into `.materialize` on the driver -- it's a clean way to do this in a more
677
+ ad-hoc/decoupled manner.
678
+
679
+ If you want to layer savers, you'll have to use the target\\_ parameter, which tells the saver which node to use.
680
+
681
+ .. code-block:: python
682
+
683
+ @save_to.json(path=source("raw_data_path"), output_name_="data_save_output", target_="data")
684
+ @save_to.json(path=source("raw_data_path2"), output_name_="data_save_output2", target_="data")
685
+ def final_output(data: dict, valid_keys: List[str]) -> dict:
686
+ return [item for item in data if item in valid_keys]
687
+
688
+ """
689
+
690
+ def __call__(self, *args, **kwargs):
691
+ return SaveToDecorator(*args, **kwargs)
692
+
693
+ @classmethod
694
+ def decorator_factory(
695
+ cls, savers: typing.Sequence[type[DataSaver]]
696
+ ) -> Callable[..., SaveToDecorator]:
697
+ """Effectively a partial function for the load_from decorator. Broken into its own (
698
+ rather than using functools.partial) as it is a little clearer to parse.
699
+
700
+ :param savers: Candidate data savers
701
+ :param loaders: Options of data loader classes to use
702
+ :return: The data loader decorator.
703
+ """
704
+
705
+ def create_decorator(__savers=tuple(savers), **kwargs: ParametrizedDependency):
706
+ return SaveToDecorator(__savers, **kwargs)
707
+
708
+ return create_decorator
709
+
710
+
711
+ class dataloader(NodeCreator):
712
+ """
713
+ Decorator for specifying a data loading function within the Hamilton framework. This decorator
714
+ is used to annotate functions that load data, allowing them to be treated specially in the Hamilton
715
+ DAG (Directed Acyclic Graph). The decorated function should return a tuple
716
+ containing the loaded data and a dictionary of metadata about the loading process.
717
+
718
+ The `dataloader` decorator captures loading data metadata and ensures the function's return
719
+ type is correctly annotated to be a tuple, where the first element is the loaded data and the
720
+ second element is a dictionary containing metadata about the data loading process.
721
+
722
+ **Downstream functions need only to depend on the type of data loaded.**
723
+
724
+ Example Usage:
725
+ --------------
726
+ Assuming you have a function that loads data from a JSON file and you want to expose the metadata in
727
+ your Hamilton DAG to be captured in the Hamilton UI / adapters:
728
+
729
+ .. code-block:: python
730
+
731
+ import pandas as pd
732
+ from hamilton.function_modifiers import dataloader
733
+
734
+
735
+ @dataloader() # you need ()
736
+ def load_json_data(json_path: str = "data/my_data.json") -> tuple[pd.DataFrame, dict]:
737
+ '''Loads a dataframe from a JSON file.
738
+
739
+ :return: A tuple containing two dictionaries:
740
+ - The first dictionary contains the loaded JSON data as a dataframe
741
+ - The second dictionary contains metadata about the loading process.
742
+ '''
743
+ # Load the data
744
+ data = pd.read_json(json_path)
745
+
746
+ # Metadata about the loading process
747
+ metadata = {"source": json_path, "format": "json"}
748
+
749
+ return data, metadata
750
+
751
+ """
752
+
753
+ def validate(self, fn: Callable):
754
+ """Validates that the output type is correctly annotated."""
755
+ return_annotation = typing.get_type_hints(fn).get("return")
756
+ if return_annotation is inspect.Signature.empty:
757
+ raise InvalidDecoratorException(
758
+ f"Function: {fn.__qualname__} must have a return annotation."
759
+ )
760
+ # check that the type is a tuple[TYPE, dict]:
761
+ if not typing_inspect.is_tuple_type(return_annotation):
762
+ raise InvalidDecoratorException(f"Function: {fn.__qualname__} must return a tuple.")
763
+ # check that there are two
764
+ if len(typing_inspect.get_args(return_annotation)) != 2:
765
+ raise InvalidDecoratorException(
766
+ f"Function: {fn.__qualname__} must return a tuple of length 2."
767
+ )
768
+ # check that the second is a dict
769
+ second_arg = typing_inspect.get_args(return_annotation)[1]
770
+ if not (custom_subclass_check(second_arg, dict)):
771
+ raise InvalidDecoratorException(
772
+ f"Function: {fn.__qualname__} must return a tuple of type (SOME_TYPE, dict)."
773
+ )
774
+ second_arg_params = typing_inspect.get_args(second_arg)
775
+ if (
776
+ len(second_arg_params) > 0 and not second_arg_params[0] == str
777
+ ): # metadata must have string keys
778
+ raise InvalidDecoratorException(
779
+ f"Function: {fn.__qualname__} must return a tuple of type (SOME_TYPE, dict[str, ...]). Instead got (SOME_TYPE, dict[{second_arg_params[0]}, ...]"
780
+ )
781
+
782
+ def generate_nodes(self, fn: Callable, config) -> list[node.Node]:
783
+ """Generates two nodes. We have to add tags appropriately.
784
+
785
+ The first one is just the fn - with a slightly different name.
786
+ The second one uses the proper function name, but only returns
787
+ the first part of the tuple that the first returns.
788
+
789
+ :param fn:
790
+ :param config:
791
+ :return:
792
+ """
793
+ _name = "loader"
794
+ og_node = node.Node.from_fn(fn, name=_name)
795
+ new_tags = og_node.tags.copy()
796
+ new_tags.update(
797
+ # we need this to be common with the loader tags above.
798
+ {
799
+ "hamilton.data_loader": True,
800
+ "hamilton.data_loader.has_metadata": True,
801
+ "hamilton.data_loader.node": f"{fn.__name__}",
802
+ "hamilton.data_loader.classname": f"{fn.__name__}()",
803
+ "hamilton.data_loader.source": _name,
804
+ }
805
+ )
806
+
807
+ def filter_function(**kwargs):
808
+ return kwargs[f"{fn.__name__}.{_name}"][0]
809
+
810
+ filter_node = node.Node(
811
+ name=fn.__name__, # use original function name
812
+ callabl=filter_function,
813
+ typ=typing_inspect.get_args(og_node.type)[0],
814
+ input_types={f"{fn.__name__}.{_name}": og_node.type},
815
+ # we need this to be common with the loader tags above.
816
+ tags={
817
+ "hamilton.data_loader": True,
818
+ "hamilton.data_loader.has_metadata": False,
819
+ "hamilton.data_loader.node": f"{fn.__name__}",
820
+ "hamilton.data_loader.classname": f"{fn.__name__}()",
821
+ "hamilton.data_loader.source": fn.__name__,
822
+ },
823
+ )
824
+
825
+ return [og_node.copy_with(tags=new_tags, namespace=(fn.__name__,)), filter_node]
826
+
827
+
828
+ class datasaver(NodeCreator):
829
+ """
830
+ Decorator for specifying a data saving function within the Hamilton framework. This decorator
831
+ is used to annotate functions that save data, allowing them to be treated specially in the Hamilton
832
+ DAG (Directed Acyclic Graph). The decorated function should return a dictionary containing metadata
833
+ about the saving process.
834
+
835
+ The `datasaver` decorator captures saving data metadata and ensures the function's return
836
+ type is correctly annotated to be a dictionary, where the dictionary contains metadata about
837
+ the data saving process, that then is exposed / captures for the Hamilton UI / adapters.
838
+
839
+ Example Usage:
840
+ --------------
841
+ Assuming you have a function that saves data to a JSON file and you want to expose the metadata in
842
+ your Hamilton DAG to be captured in the Hamilton UI / adapters:
843
+
844
+ .. code-block:: python
845
+
846
+ import pandas as pd
847
+ from hamilton.function_modifiers import datasaver
848
+
849
+
850
+ @datasaver() # you need ()
851
+ def save_json_data(data: pd.DataFrame, json_path: str = "data/my_saved_data.json") -> dict:
852
+ '''Saves data to a JSON file and returns metadata about the saving process.
853
+
854
+ :param data: The data to save.
855
+ :param json_path: The path to save the data to.
856
+ :return: metadata about what was saved.
857
+ '''
858
+ # Save the data
859
+ with open(json_path, "w") as file:
860
+ data.to_json(json_path)
861
+
862
+ # Metadata about the saving process
863
+ metadata = {"destination": json_path, "format": "json"}
864
+
865
+ return metadata
866
+
867
+ This function can now be used within the Hamilton framework as a node that saves data to
868
+ a JSON file. The metadata returned alongside the data can be used for logging, debugging, or
869
+ any other purpose that requires information about the data saving process as it can be pulled
870
+ out by the Hamilton Tracker for the Hamilton UI or other adapters.
871
+ """
872
+
873
+ def validate(self, fn: Callable):
874
+ """Validates that the function output is a dict type."""
875
+ return_annotation = inspect.signature(fn).return_annotation
876
+ if return_annotation is inspect.Signature.empty:
877
+ raise InvalidDecoratorException(
878
+ f"Function: {fn.__qualname__} must have a return annotation."
879
+ )
880
+ # check that the return type is a dict
881
+ if return_annotation not in (dict, dict):
882
+ raise InvalidDecoratorException(f"Function: {fn.__qualname__} must return a dict.")
883
+
884
+ def generate_nodes(self, fn: Callable, config) -> list[node.Node]:
885
+ """Generates same node but all this does is add tags to it.
886
+ :param fn:
887
+ :param config:
888
+ :return:
889
+ """
890
+ og_node = node.Node.from_fn(fn)
891
+ new_tags = og_node.tags.copy()
892
+ new_tags.update(
893
+ # we need this to be common with the saver tags above.
894
+ {
895
+ "hamilton.data_saver": True,
896
+ "hamilton.data_saver.sink": f"{og_node.name}",
897
+ "hamilton.data_saver.classname": f"{fn.__name__}()",
898
+ }
899
+ )
900
+ return [og_node.copy_with(tags=new_tags)]