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,908 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ import abc
19
+ import inspect
20
+ import sys
21
+ import typing
22
+ from collections import defaultdict
23
+ from collections.abc import Callable, Collection
24
+ from types import ModuleType
25
+ from typing import Any, TypedDict
26
+
27
+ _sys_version_info = sys.version_info
28
+ _version_tuple = (_sys_version_info.major, _sys_version_info.minor, _sys_version_info.micro)
29
+
30
+ if _version_tuple < (3, 11, 0):
31
+ from typing_extensions import NotRequired
32
+ else:
33
+ from typing import NotRequired
34
+
35
+ # Copied this over from function_graph
36
+ # TODO -- determine the best place to put this code
37
+ from hamilton import graph_utils, node
38
+ from hamilton.function_modifiers import base, dependencies
39
+ from hamilton.function_modifiers.base import InvalidDecoratorException, NodeTransformer
40
+ from hamilton.function_modifiers.dependencies import (
41
+ LiteralDependency,
42
+ ParametrizedDependency,
43
+ UpstreamDependency,
44
+ )
45
+
46
+
47
+ def assign_namespace(node_name: str, namespace: str) -> str:
48
+ return f"{namespace}.{node_name}"
49
+
50
+
51
+ def derive_type(dependency: dependencies.LiteralDependency):
52
+ """Quick hack to derive the type of a static dependency.
53
+ We might want to consider the type provided by the function that needs it.
54
+ Or we can use the subclass checker/whatnot in function_graph
55
+ (althoyugh we'll want to move it out)
56
+
57
+ :param dependency: Dependency on which
58
+ :return: The type of the dependency
59
+ """
60
+ return type(dependency.value)
61
+
62
+
63
+ def create_identity_node(
64
+ from_: str, typ: type[type], name: str, namespace: tuple[str, ...], tags: dict[str, Any]
65
+ ) -> node.Node:
66
+ """Creates an identity node -- this passes through the exact
67
+ value returned by the upstream node.
68
+
69
+ :param from_: Source node
70
+ :param typ: Type of the input node
71
+ :param name: Name of the final node to create
72
+ :param namespace: Namespace of the node
73
+ :return: A node that simply copies the source node
74
+ """
75
+
76
+ def identity(**kwargs):
77
+ return list(kwargs.values())[0] # Maybe come up with a better way to do this
78
+
79
+ return node.Node(
80
+ name=name,
81
+ typ=typ,
82
+ doc_string="",
83
+ callabl=identity,
84
+ input_types={from_: typ},
85
+ namespace=namespace,
86
+ tags=tags,
87
+ # TODO -- add tags?
88
+ )
89
+
90
+
91
+ def extract_all_known_types(nodes: Collection[node.Node]) -> dict[str, type[type]]:
92
+ """Extracts all known types from a set of nodes given the dependencies.
93
+ We have to do this as we don't know the dependency types at compile-time of
94
+ upstream nodes. That said, this is only used for guessing dependency types of
95
+ identity nodes. In which case, we probably want some sort of sentinel "pass-through"
96
+ dependency type that handles this better. But, for now, we'll derive it from the
97
+ dependencies we've seen.
98
+
99
+ :param nodes: nodes to look through for dependencies
100
+ :return: A dictionary of all known types.
101
+ """
102
+ observed_types = {}
103
+ for node_ in nodes:
104
+ for dep_name, (type_, _) in node_.input_types.items():
105
+ observed_types[dep_name] = type_
106
+ return observed_types
107
+
108
+
109
+ def create_static_node(
110
+ typ: type, name: str, value: Any, namespace: tuple[str, ...], tags: dict[str, Any]
111
+ ) -> node.Node:
112
+ """Utility function to create a static node -- this helps us bridge nodes together.
113
+
114
+ :param typ: Type of the node to create
115
+ :param name: Name of the node to create
116
+ :param value: Value that the node's function always returns
117
+ :param namespace: Namespace of the node
118
+ :return: The instantiated static node
119
+ """
120
+
121
+ def node_fn(_value=value):
122
+ return _value
123
+
124
+ return node.Node(
125
+ name=name, typ=typ, callabl=node_fn, input_types={}, namespace=namespace, tags=tags
126
+ )
127
+
128
+
129
+ def _validate_config_inputs(config: dict[str, Any], inputs: dict[str, Any]):
130
+ """Validates that the inputs specified in the config are valid.
131
+
132
+ :param original_config: Original configuration
133
+ :return: None
134
+ """
135
+ # TODO -- implement this
136
+ shared_keys = set(config.keys()).intersection(set(inputs.keys()))
137
+ if shared_keys:
138
+ raise InvalidDecoratorException(
139
+ f"Config keys {shared_keys} are shared with inputs. This is not allowed."
140
+ f"Instead, please specify the inputs you need *just* as part of the config. "
141
+ f"That way, you only write them once! Or, if you don't need them as a config item,"
142
+ f"just use them in inputs."
143
+ )
144
+ for key, value in inputs.items():
145
+ if not isinstance(value, (UpstreamDependency, LiteralDependency)):
146
+ raise InvalidDecoratorException(
147
+ f"Input {key} must be either an UpstreamDependency or a LiteralDependency ,"
148
+ f" not {type(value)}."
149
+ )
150
+
151
+
152
+ def _resolve_subdag_configuration(
153
+ configuration: dict[str, Any], fields: dict[str, Any], function_name: str
154
+ ) -> dict[str, Any]:
155
+ """Resolves the configuration for a subdag.
156
+
157
+ :param configuration: the Hamilton configuration
158
+ :param fields: the fields passed to the subdag decorator
159
+ :return: resolved configuration to use for this subdag.
160
+ """
161
+ sources_to_map = {}
162
+ values_to_include = {}
163
+ for key, value in fields.items():
164
+ if isinstance(value, dependencies.ConfigDependency):
165
+ sources_to_map[key] = value.source
166
+ elif isinstance(value, dependencies.LiteralDependency):
167
+ values_to_include[key] = value.value
168
+ elif isinstance(value, (dependencies.GroupedDependency, dependencies.SingleDependency)):
169
+ raise InvalidDecoratorException(
170
+ f"`{value}` is not allowed in the config= part of the subdag decorator. "
171
+ "Please use `configuration()` or `value()` or literal python values."
172
+ )
173
+ plain_configs = {
174
+ k: v for k, v in fields.items() if k not in sources_to_map and k not in values_to_include
175
+ }
176
+ resolved_config = dict(configuration, **plain_configs, **values_to_include)
177
+
178
+ # override any values from sources
179
+ for key, source in sources_to_map.items():
180
+ try:
181
+ resolved_config[key] = resolved_config[source]
182
+ except KeyError as e:
183
+ raise InvalidDecoratorException(
184
+ f"Source {source} was not found in the configuration. This is required for the {function_name} subdag."
185
+ ) from e
186
+ return resolved_config
187
+
188
+
189
+ NON_FINAL_TAGS = {NodeTransformer.NON_FINAL_TAG: True}
190
+
191
+
192
+ class subdag(base.NodeCreator):
193
+ """The `@subdag` decorator enables you to rerun components of your DAG with varying parameters.
194
+ That is, it enables you to "chain" what you could express with a driver into a single DAG.
195
+
196
+ That is, instead of using Hamilton within itself:
197
+
198
+ .. code-block:: python
199
+
200
+ def feature_engineering(source_path: str) -> pd.DataFrame:
201
+ '''You could recursively use Hamilton within itself.'''
202
+ dr = driver.Driver({}, feature_modules)
203
+ df = dr.execute(["feature_df"], inputs={"path": source_path})
204
+ return df
205
+
206
+ You instead can use the `@subdag` decorator to do the same thing, with the added benefit of visibility into the\
207
+ whole DAG:
208
+
209
+ .. code-block:: python
210
+
211
+ @subdag(
212
+ feature_modules,
213
+ inputs={"path": source("source_path")},
214
+ config={}
215
+ )
216
+ def feature_engineering(feature_df: pd.DataFrame) -> pd.DataFrame:
217
+ return feature_df
218
+
219
+
220
+ Note that this is immensely powerful -- if we draw analogies from Hamilton to standard procedural programming \
221
+ paradigms, we might have the following correspondence:
222
+
223
+ - `config.when` + friends -- `if/else` statements
224
+ - `parameterize`/`extract_columns` -- `for` loop
225
+ - `does` -- effectively macros
226
+
227
+ And so on. `@subdag` takes this one step further:
228
+
229
+ - `@subdag` -- subroutine definition
230
+
231
+ E.G. take a certain set of nodes, and run them with specified parameters.
232
+
233
+ @subdag declares parameters that are outputs of its subdags. Note that, if you want to use outputs of other
234
+ components of the DAG, you can use the `external_inputs` parameter to declare the parameters that do *not* come
235
+ from the subDAG.
236
+
237
+ Why might you want to use this? Let's take a look at some examples:
238
+
239
+ 1. You have a feature engineering pipeline that you want to run on multiple datasets. If its exactly the same, \
240
+ this is perfect. If not, this works perfectly as well, you just have to utilize different functions in each or \
241
+ the `config.when` + `config` parameter to rerun it.
242
+ 2. You want to train multiple models in the same DAG that share some logic (in features or training) -- this \
243
+ allows you to reuse and continually add more.
244
+ 3. You want to combine multiple similar DAGs (e.g. one for each business line) into one so you can build a \
245
+ cross-business line model.
246
+
247
+ This basically bridges the gap between the flexibility of non-declarative pipelining frameworks with the \
248
+ readability/maintainability of declarative ones.
249
+ """
250
+
251
+ def __init__(
252
+ self,
253
+ *load_from: ModuleType | Callable,
254
+ inputs: dict[str, ParametrizedDependency] = None,
255
+ config: dict[str, Any] = None,
256
+ namespace: str = None,
257
+ final_node_name: str = None,
258
+ external_inputs: list[str] = None,
259
+ ):
260
+ """Adds a subDAG to the main DAG.
261
+
262
+ :param load_from: The functions that will be used to generate this subDAG.
263
+ :param inputs: Parameterized dependencies to inject into all sources of this subDAG.
264
+ This should *not* be an intermediate node in the subDAG.
265
+ :param config: A configuration dictionary for *just* this subDAG. Note that this passed in
266
+ value takes precedence over the DAG's config.
267
+ :param namespace: Namespace with which to prefix nodes. This is optional -- if not included,
268
+ this will default to the function name.
269
+ :param final_node_name: Name of the final node in the subDAG. This is optional -- if not included,
270
+ this will default to the function name.
271
+ :param external_inputs: Parameters in the function that are not produced by the functions
272
+ passed to the subdag. This is useful if you want to perform some logic with other inputs
273
+ in the subdag's processing function. Note that this is currently required to
274
+ differentiate and clarify the inputs to the subdag.
275
+
276
+ """
277
+ self.subdag_functions = subdag.collect_functions(load_from)
278
+ self.inputs = inputs if inputs is not None else {}
279
+ self.config = config if config is not None else {}
280
+ self.external_inputs = external_inputs if external_inputs is not None else []
281
+ _validate_config_inputs(self.config, self.inputs)
282
+ self.namespace = namespace
283
+ self.final_node_name = final_node_name
284
+
285
+ @staticmethod
286
+ def collect_functions(
287
+ load_from: Collection[ModuleType] | Collection[Callable],
288
+ ) -> list[Callable]:
289
+ """Utility function to collect functions from a list of callables/modules.
290
+
291
+ :param load_from: A list of callables or modules to load from
292
+ :return: a list of callables to use to create a DAG.
293
+ """
294
+ if len(load_from) == 0:
295
+ raise ValueError(f"No functions were passed to {subdag.__name__}(load_from=...)")
296
+ out = []
297
+ for item in load_from:
298
+ if isinstance(item, Callable):
299
+ out.append(item)
300
+ out.extend(
301
+ [function for _, function in graph_utils.find_functions(function_module=item)]
302
+ )
303
+ return out
304
+
305
+ @staticmethod
306
+ def collect_nodes(config: dict[str, Any], subdag_functions: list[Callable]) -> list[node.Node]:
307
+ nodes = []
308
+ for fn in subdag_functions:
309
+ for node_ in base.resolve_nodes(fn, config):
310
+ nodes.append(node_.copy_with(tags={**node_.tags, **NON_FINAL_TAGS}))
311
+ return nodes
312
+
313
+ def _create_additional_static_nodes(
314
+ self, nodes: Collection[node.Node], namespace: str
315
+ ) -> Collection[node.Node]:
316
+ # These already have the namespace on them
317
+ # This allows us to inject values into the replayed subdag
318
+ node_types = extract_all_known_types(nodes)
319
+ out = []
320
+ for key, value in self.inputs.items():
321
+ # TODO -- fix type derivation. Currently we don't use the specified type as we don't
322
+ # really know what it should be...
323
+ new_node_name = assign_namespace(key, namespace)
324
+ if value.get_dependency_type() == dependencies.ParametrizedDependencySource.LITERAL:
325
+ out.append(
326
+ create_static_node(
327
+ typ=derive_type(value),
328
+ name=key,
329
+ value=value.value,
330
+ namespace=(namespace,),
331
+ tags=NON_FINAL_TAGS,
332
+ )
333
+ )
334
+ elif value.get_dependency_type() == dependencies.ParametrizedDependencySource.UPSTREAM:
335
+ if new_node_name not in node_types:
336
+ continue
337
+ out.append(
338
+ create_identity_node(
339
+ from_=value.source,
340
+ typ=node_types[new_node_name],
341
+ name=key,
342
+ namespace=(namespace,),
343
+ tags=NON_FINAL_TAGS,
344
+ )
345
+ )
346
+ for key, value in self.config.items():
347
+ out.append(
348
+ create_static_node(
349
+ typ=type(value),
350
+ name=key,
351
+ value=value,
352
+ namespace=(namespace,),
353
+ tags=NON_FINAL_TAGS,
354
+ )
355
+ )
356
+ return out
357
+
358
+ @staticmethod
359
+ def add_namespace(
360
+ nodes: list[node.Node],
361
+ namespace: str,
362
+ inputs: dict[str, Any] = None,
363
+ config: dict[str, Any] = None,
364
+ ) -> list[node.Node]:
365
+ """Utility function to add a namespace to nodes.
366
+
367
+ :param nodes:
368
+ :return:
369
+ """
370
+ inputs = inputs if inputs is not None else {}
371
+ config = config if config is not None else {}
372
+ new_nodes = []
373
+ new_name_map = {}
374
+ # First pass we validate + collect names so we can alter dependencies
375
+ for node_ in nodes:
376
+ new_name = assign_namespace(node_.name, namespace)
377
+ new_name_map[node_.name] = new_name
378
+ for dep, _value in inputs.items():
379
+ # We create nodes for both namespace assignment and source assignment
380
+ # Why? Cause we need unique parameter names, and with source() some can share params
381
+ new_name_map[dep] = assign_namespace(dep, namespace)
382
+
383
+ for dep, _value in config.items():
384
+ new_name_map[dep] = assign_namespace(dep, namespace)
385
+
386
+ # Reassign sources
387
+ for node_ in nodes:
388
+ # This is not perfect -- we might get strangeness if its dynamically generated
389
+ # that said, it should work
390
+ is_async = inspect.iscoroutinefunction(node_.callable)
391
+ new_name = new_name_map[node_.name]
392
+ kwarg_mapping = {
393
+ (new_name_map[key] if key in new_name_map else key): key
394
+ for key in node_.input_types
395
+ }
396
+
397
+ # Map of argument in function to source, can't be the other way
398
+ # around as sources can potentially serve multiple destinations (with the source()) decorator
399
+ def fn(
400
+ _callabl=node_.callable,
401
+ _kwarg_mapping=dict(kwarg_mapping), # noqa: B006
402
+ _new_name=new_name,
403
+ _new_name_map=dict(new_name_map), # noqa: B006
404
+ **kwargs,
405
+ ):
406
+ new_kwargs = {_kwarg_mapping[kwarg]: value for kwarg, value in kwargs.items()}
407
+ return _callabl(**new_kwargs)
408
+
409
+ async def async_fn(
410
+ _callabl=node_.callable,
411
+ _kwarg_mapping=dict(kwarg_mapping), # noqa: B006
412
+ _new_name=new_name,
413
+ _new_name_map=dict(new_name_map), # noqa: B006
414
+ **kwargs,
415
+ ):
416
+ new_kwargs = {_kwarg_mapping[kwarg]: value for kwarg, value in kwargs.items()}
417
+ return await _callabl(**new_kwargs)
418
+
419
+ new_input_types = {
420
+ dep: node_.input_types[original_dep] for dep, original_dep in kwarg_mapping.items()
421
+ }
422
+ fn_to_use = async_fn if is_async else fn
423
+
424
+ new_nodes.append(
425
+ node_.copy_with(input_types=new_input_types, name=new_name, callabl=fn_to_use)
426
+ )
427
+ return new_nodes
428
+
429
+ def add_final_node(self, fn: Callable, node_name: str, namespace: str):
430
+ """
431
+
432
+ :param fn:
433
+ :return:
434
+ """
435
+ is_async = inspect.iscoroutinefunction(fn) # determine if its async
436
+ node_ = node.Node.from_fn(fn)
437
+ namespaced_input_map = {
438
+ (assign_namespace(key, namespace) if key not in self.external_inputs else key): key
439
+ for key in node_.input_types
440
+ }
441
+
442
+ new_input_types = {
443
+ (assign_namespace(key, namespace) if key not in self.external_inputs else key): value
444
+ for key, value in node_.input_types.items()
445
+ }
446
+
447
+ def new_function(**kwargs):
448
+ kwargs_without_namespace = {
449
+ namespaced_input_map[key]: value for key, value in kwargs.items()
450
+ }
451
+ # Have to translate it back to use the kwargs the fn is expecting
452
+ return fn(**kwargs_without_namespace)
453
+
454
+ async def async_function(**kwargs):
455
+ return await new_function(**kwargs)
456
+
457
+ fn_to_use = async_function if is_async else new_function
458
+
459
+ return node_.copy_with(name=node_name, input_types=new_input_types, callabl=fn_to_use)
460
+
461
+ def _derive_namespace(self, fn: Callable) -> str:
462
+ """Utility function to derive a namespace from a function.
463
+
464
+ :param fn: Function we're decorating.
465
+ :return: The function we're outputting.
466
+ """
467
+ return fn.__name__ if self.namespace is None else self.namespace
468
+
469
+ def _derive_name(self, fn: Callable) -> str:
470
+ """Utility function to derive a name from a function.
471
+ The user will be able to likely pass this in as an override, but
472
+ we have not exposed it yet.
473
+
474
+ :param fn: Function we're decorating.
475
+ :return: The function we're outputting.
476
+ """
477
+ return fn.__name__ if self.final_node_name is None else self.final_node_name
478
+
479
+ def generate_nodes(self, fn: Callable, configuration: dict[str, Any]) -> Collection[node.Node]:
480
+ # Resolve all nodes from passed in functions
481
+ # if self.config has configuration() or value() in it, we need to resolve it
482
+ resolved_config = _resolve_subdag_configuration(configuration, self.config, fn.__name__)
483
+ # resolved_config = dict(configuration, **self.config)
484
+ nodes = self.collect_nodes(config=resolved_config, subdag_functions=self.subdag_functions)
485
+ # Derive the namespace under which all these nodes will live
486
+ namespace = self._derive_namespace(fn)
487
+ final_node_name = self._derive_name(fn)
488
+ # Rename them all to have the right namespace
489
+ nodes = self.add_namespace(nodes, namespace, self.inputs, self.config)
490
+ # Create any static input nodes we need to translate
491
+ nodes += self._create_additional_static_nodes(nodes, namespace)
492
+ # Add the final node that does the translation
493
+ nodes += [self.add_final_node(fn, final_node_name, namespace)]
494
+ return nodes
495
+
496
+ def _validate_parameterization(self):
497
+ invalid_values = []
498
+ for _key, value in self.inputs.items():
499
+ if not isinstance(value, dependencies.ParametrizedDependency):
500
+ invalid_values.append(value)
501
+ if invalid_values:
502
+ raise ValueError(
503
+ f"Parameterization using the following values is not permitted -- "
504
+ f"must be either source() or value(): {invalid_values}"
505
+ )
506
+
507
+ def validate(self, fn):
508
+ """Validates everything we can before we create the subdag.
509
+
510
+ :param fn: Function that this decorates
511
+ :raises InvalidDecoratorException: if this is not a valid decorator
512
+ """
513
+
514
+ self._validate_parameterization()
515
+
516
+ def required_config(self) -> list[str] | None:
517
+ """Currently we do not filter for subdag as we do not *statically* know what configuration
518
+ is required. This is because we need to parse the function so that we can figure it out,
519
+ and that is not available at the time that we call required_config. We need to think about
520
+ the best way to do this, but its likely that we'll want to allow required_config to consume
521
+ the function itself, and pass it in when its called with that.
522
+
523
+ That said, we don't have sufficient justification to do that yet, so we're just going to
524
+ return None for now, meaning that it has access to all configuration variables.
525
+
526
+ :return:
527
+ """
528
+ return None
529
+
530
+
531
+ class SubdagParams(TypedDict):
532
+ inputs: NotRequired[dict[str, ParametrizedDependency]]
533
+ config: NotRequired[dict[str, Any]]
534
+ external_inputs: NotRequired[list[str]]
535
+
536
+
537
+ class parameterized_subdag(base.NodeCreator):
538
+ """parameterized subdag is when you want to create multiple subdags at one time.
539
+ Why might you want to do this?
540
+
541
+ 1. You have multiple data sets you want to run the same feature engineering pipeline on.
542
+ 2. You want to run some sort of optimization routine with a variety of results
543
+ 3. You want to run some sort of pipeline over slightly different configuration (E.G. region/business line)
544
+
545
+ Note that this really is just syntactic sugar for creating multiple subdags, just as `@parameterize
546
+ is syntactic sugar for creating multiple nodes from a function. That said, it is common that you
547
+ won't know what you want until compile time (E.G. when you have the config available), so this
548
+ decorator along with the `@resolve` decorator is a good way to make that feasible. Note that
549
+ we are getting into *advanced* Hamilton here -- we don't recommend starting with this. In fact,
550
+ we generally recommend repeating subdags multiple times if you don't have too many. That said,
551
+ that can get cumbersome if you have a lot, so this decorator is a good way to help with that.
552
+
553
+ Let's take a look at an example:
554
+
555
+ .. code-block:: python
556
+
557
+ @parameterized_subdag(
558
+ feature_modules,
559
+ from_datasource_1={"inputs" : {"data" : value("datasource_1.csv")}},
560
+ from_datasource_2={"inputs" : {"data" : value("datasource_2.csv")}},
561
+ from_datasource_3={
562
+ "inputs" : {"data" : value("datasource_3.csv")},
563
+ "config" : {"filter" : "only_even_client_ids"}
564
+ }
565
+ )
566
+ def feature_engineering(feature_df: pd.DataFrame) -> pd.DataFrame:
567
+ return feature_df
568
+
569
+ This is (obviously) contrived, but what it does is create three subdags, each with a different
570
+ data source. The third one also applies a configuration to that subdags. Note that we can also
571
+ pass in inputs/config to the decorator itself, which will be applied to all subdags.
572
+
573
+ This is effectively the same as the example above.
574
+
575
+ .. code-block:: python
576
+
577
+ @parameterized_subdag(
578
+ feature_modules,
579
+ inputs={"data" : value("datasource_1.csv")},
580
+ from_datasource_1={},
581
+ from_datasource_2={
582
+ "inputs" : {"data" : value("datasource_2.csv")}
583
+ },
584
+ from_datasource_3={
585
+ "inputs" : {"data" : value("datasource_3.csv")},
586
+ "config" : {"filter" : "only_even_client_ids"},
587
+ }
588
+ )
589
+
590
+ Again, think about whether this feature is really the one you want -- often times, verbose,
591
+ static DAGs are far more readable than very concise, highly parameterized DAGs.
592
+ """
593
+
594
+ def __init__(
595
+ self,
596
+ *load_from: ModuleType | Callable,
597
+ inputs: dict[
598
+ str, dependencies.ParametrizedDependency | dependencies.LiteralDependency
599
+ ] = None,
600
+ config: dict[str, Any] = None,
601
+ external_inputs: list[str] = None,
602
+ **parameterization: SubdagParams,
603
+ ):
604
+ """Initializes a parameterized_subdag decorator.
605
+
606
+ :param load_from: Modules to load from
607
+ :param inputs: Inputs for each subdag generated by the decorated function
608
+ :param config: Config for each subdag generated by the decorated function
609
+ :param external_inputs: External inputs to all parameterized subdags. Note that
610
+ if you pass in any external inputs from local subdags, it overrides this (does not merge).
611
+ :param parameterization: Parameterizations for each subdag generated.
612
+ Note that this *overrides* any inputs/config passed to the decorator itself.
613
+
614
+ Furthermore, note the following:
615
+
616
+ 1. The parameterizations passed to the constructor are \\*\\*kwargs, so you are not
617
+ allowed to name these `load_from`, `inputs`, or `config`. That's a good thing, as these
618
+ are not good names for variables anyway.
619
+
620
+ 2. Any empty items (not included) will default to an empty dict (or an empty list in
621
+ the case of parameterization)
622
+ """
623
+ self.load_from = load_from
624
+ self.inputs = inputs if inputs is not None else {}
625
+ self.config = config if config is not None else {}
626
+ self.parameterization = parameterization
627
+ self.external_inputs = external_inputs if external_inputs is not None else []
628
+
629
+ def _gather_subdag_generators(self) -> list[subdag]:
630
+ subdag_generators = []
631
+ for key, parameterization in self.parameterization.items():
632
+ subdag_generators.append(
633
+ subdag(
634
+ *self.load_from,
635
+ inputs={**self.inputs, **parameterization.get("inputs", {})},
636
+ config={**self.config, **parameterization.get("config", {})},
637
+ external_inputs=parameterization.get("external_inputs", self.external_inputs),
638
+ namespace=key,
639
+ final_node_name=key,
640
+ )
641
+ )
642
+ return subdag_generators
643
+
644
+ def generate_nodes(self, fn: Callable, config: dict[str, Any]) -> list[node.Node]:
645
+ generated_nodes = []
646
+ for subdag_generator in self._gather_subdag_generators():
647
+ generated_nodes.extend(subdag_generator.generate_nodes(fn, config))
648
+ return generated_nodes
649
+
650
+ def validate(self, fn: Callable):
651
+ for subdag_generator in self._gather_subdag_generators():
652
+ subdag_generator.validate(fn)
653
+
654
+ def required_config(self) -> list[str] | None:
655
+ """See note for subdag.required_config -- this is the same pattern.
656
+
657
+ :return: Any required config items.
658
+ """
659
+ return None
660
+
661
+
662
+ def prune_nodes(nodes: list[node.Node], select: list[str] | None = None) -> list[node.Node]:
663
+ """Prunes the nodes to only include those upstream from the select columns.
664
+ Conducts a depth-first search using the nodes `input_types` field.
665
+
666
+ If select is None, we just assume all nodes should be included.
667
+
668
+ :param nodes: Full set of nodes
669
+ :param select: Columns to select
670
+ :return: Pruned set of nodes
671
+ """
672
+ if select is None:
673
+ return nodes
674
+
675
+ node_name_map = {node_.name: node_ for node_ in nodes}
676
+ seen_nodes = set(select)
677
+ stack = list({node_name_map[col] for col in select if col in node_name_map})
678
+ output = []
679
+ while len(stack) > 0:
680
+ node_ = stack.pop()
681
+ output.append(node_)
682
+ for dep in node_.input_types:
683
+ if dep not in seen_nodes and dep in node_name_map:
684
+ dep_node = node_name_map[dep]
685
+ stack.append(dep_node)
686
+ seen_nodes.add(dep)
687
+
688
+ if not set(select) <= set([node_.name for node_ in output]):
689
+ raise ValueError(
690
+ "At least one of the selected nodes is not not in the DAG. "
691
+ f"You selected: {select}, but we only found nodes: {nodes}."
692
+ )
693
+ return output
694
+
695
+
696
+ def _default_inject_parameter(fn: Callable, target_dataframe: str = None) -> str:
697
+ if target_dataframe is not None:
698
+ inject_parameter = target_dataframe
699
+ else:
700
+ # If we don't have a specified dataframe we assume it's the first argument
701
+ function_parameters = list(inspect.signature(fn).parameters.values())
702
+ if function_parameters:
703
+ inject_parameter = function_parameters[0].name
704
+ else:
705
+ raise ValueError(
706
+ f"Function {fn.__qualname__} has no parameters, but was "
707
+ f"decorated with with_columns. with_columns requires the first "
708
+ f"parameter to be a dataframe or using the on_input argument."
709
+ )
710
+ return inject_parameter
711
+
712
+
713
+ class with_columns_base(base.NodeInjector, abc.ABC):
714
+ """Factory for with_columns operation on a dataframe. This is used when you want to extract some
715
+ columns out of the dataframe, perform operations on them and then append to the original dataframe.
716
+
717
+ This is an internal class that is meant to be extended by each individual dataframe library implementing
718
+ the following abstract methods:
719
+
720
+ - get_initial_nodes
721
+ - get_subdag_nodes
722
+ - chain_subdag_nodes
723
+ - validate
724
+ """
725
+
726
+ # TODO: if we rename the column nodes into something smarter this can be avoided and
727
+ # can also modify columns in place
728
+ @staticmethod
729
+ def contains_duplicates(nodes_: list[node.Node]) -> bool:
730
+ """Ensures that we don't run into name clashing of columns and group operations.
731
+
732
+ In the case when we extract columns for the user, because ``columns_to_pass`` was used, we want
733
+ to safeguard against nameclashing with functions that are passed into ``with_columns`` - i.e.
734
+ there are no functions that have the same name as the columns. This effectively means that
735
+ using ``columns_to_pass`` will only append new columns to the dataframe and for changing
736
+ existing columns ``pass_dataframe_as`` or ``on_input`` needs to be used.
737
+ """
738
+ node_counter = defaultdict(int)
739
+ for node_ in nodes_:
740
+ node_counter[node_.name] += 1
741
+ if node_counter[node_.name] > 1:
742
+ return True
743
+ return False
744
+
745
+ @staticmethod
746
+ def validate_dataframe(
747
+ fn: Callable, inject_parameter: str, params: dict[str, type[type]], required_type: type
748
+ ) -> None:
749
+ input_types = typing.get_type_hints(fn)
750
+ if inject_parameter not in params:
751
+ raise InvalidDecoratorException(
752
+ f"Function: {fn.__name__} does not have the parameter {inject_parameter} as a dependency. "
753
+ f"@with_columns requires the parameter names to match the function parameters. "
754
+ f"If you wish do not wish to use the first argument, please use ``pass_dataframe_as`` or ``on_input`` option. "
755
+ f"It might not be compatible with some other decorators."
756
+ )
757
+
758
+ if isinstance(input_types[inject_parameter], required_type):
759
+ raise InvalidDecoratorException(
760
+ "The selected dataframe parameter is not the correct dataframe type. "
761
+ f"You selected a parameter of type {input_types[inject_parameter]}, but we expect to get {required_type}"
762
+ )
763
+
764
+ def __init__(
765
+ self,
766
+ *load_from: Callable | ModuleType,
767
+ columns_to_pass: list[str] = None,
768
+ pass_dataframe_as: str = None,
769
+ on_input: str = None,
770
+ select: list[str] = None,
771
+ namespace: str = None,
772
+ config_required: list[str] = None,
773
+ dataframe_type: type = None,
774
+ ):
775
+ """Instantiates a ``@with_columns`` decorator.
776
+
777
+ :param load_from: The functions or modules that will be used to generate the group of map operations.
778
+ :param columns_to_pass: The initial schema of the dataframe. This is used to determine which
779
+ upstream inputs should be taken from the dataframe, and which shouldn't. Note that, if this is
780
+ left empty (and external_inputs is as well), we will assume that all dependencies come
781
+ from the dataframe. This cannot be used in conjunction with pass_dataframe_as.
782
+ :param pass_dataframe_as: The name of the dataframe that we're modifying, as known to the subdag.
783
+ If you pass this in, you are responsible for extracting columns out. If not provided, you have
784
+ to pass columns_to_pass in, and we will extract the columns out for you.
785
+ :param on_input: the dataframe parameter that we are applying with_columns on. By default we
786
+ will assume the first parameter is the corresponding dataframe.
787
+ :param select: The end nodes that represent columns to be appended to the original dataframe
788
+ via with_columns. Existing columns will be overridden.
789
+ :param namespace: The namespace of the nodes, so they don't clash with the global namespace
790
+ and so this can be reused. If its left out, there will be no namespace (in which case you'll want
791
+ to be careful about repeating it/reusing the nodes in other parts of the DAG.)
792
+ :param config_required: the list of config keys that are required to resolve any functions. Pass in None\
793
+ if you want the functions/modules to have access to all possible config.
794
+ """
795
+
796
+ self.subdag_functions = subdag.collect_functions(load_from)
797
+ self.select = select
798
+
799
+ # This is here to restrict to using either pass_dataframe_as or on_input or columns_to_pass
800
+ # TODO: decouple columns_to_pass, pass_dataframe_as and on_input
801
+ # For spark, we always perform with_columns on first parameter and use pass_dataframe_as;
802
+ # for pandas/polars, we can select which dataframe with on_input, but columns_to_pass, will always only work on first parameter
803
+ # We can decouple it so that on_input selects the target dataframe parameter that will inject into the next node
804
+ # pass_dataframe_as selects the original dataframe we want to extract columns from
805
+ # columns_to_pass is optinal helper that can be toggled on/off so no need to raise this error.
806
+ if (
807
+ int(pass_dataframe_as is None) + int(columns_to_pass is None) + int(on_input is None)
808
+ == 1
809
+ ):
810
+ raise ValueError(
811
+ "You must specify only one of ``columns_to_pass``, ``pass_dataframe_as``, and ``on_input``. "
812
+ "This is because specifying ``pass_dataframe_as`` or ``on_input`` injects into "
813
+ "the set of columns, allowing you to perform your own extraction"
814
+ "from the dataframe. We then execute all columns in the subdag"
815
+ "in order, passing in that initial dataframe. If you want"
816
+ "to reference columns in your code, you'll have to specify "
817
+ "the set of initial columns, and allow the subdag decorator "
818
+ "to inject the dataframe through. The initial columns tell "
819
+ "us which parameters to take from that dataframe, so we can"
820
+ "feed the right data into the right columns."
821
+ )
822
+
823
+ self.initial_schema = columns_to_pass
824
+ self.dataframe_subdag_param = pass_dataframe_as
825
+ self.target_dataframe = on_input
826
+ self.namespace = namespace
827
+ self.config_required = config_required
828
+
829
+ if dataframe_type is None:
830
+ raise InvalidDecoratorException(
831
+ "Please provide the dataframe type for this specific library."
832
+ )
833
+
834
+ self.dataframe_type = dataframe_type
835
+
836
+ def required_config(self) -> list[str]:
837
+ return self.config_required
838
+
839
+ @abc.abstractmethod
840
+ def get_initial_nodes(
841
+ self, fn: Callable, params: dict[str, type[type]]
842
+ ) -> tuple[str, Collection[node.Node]]:
843
+ """Preparation stage where columns get extracted into nodes. In case `pass_dataframe_as` or `on_input` is
844
+ used, this should return an empty list (no column nodes) since the users will extract it
845
+ themselves.
846
+
847
+ :param fn: the function we are decorating. By using the inspect library you can get information.
848
+ about what arguments it has / find out the dataframe argument.
849
+ :param params: Dictionary of all the type names one wants to inject.
850
+ :return: name of the dataframe parameter and list of nodes representing the extracted columns (can be empty).
851
+ """
852
+ pass
853
+
854
+ @abc.abstractmethod
855
+ def get_subdag_nodes(self, fn: Callable, config: dict[str, Any]) -> Collection[node.Node]:
856
+ """Creates subdag from the passed in module / functions.
857
+
858
+ :param config: Configuration with which the DAG was constructed.
859
+ :return: the subdag as a list of nodes.
860
+ """
861
+ pass
862
+
863
+ @abc.abstractmethod
864
+ def chain_subdag_nodes(
865
+ self, fn: Callable, inject_parameter: str, generated_nodes: Collection[node.Node]
866
+ ) -> node.Node:
867
+ """Combines the origanl dataframe with selected columns. This should produce a
868
+ dataframe output that is injected into the decorated function with new columns
869
+ appended and existing columns overriden.
870
+
871
+ :param inject_parameter: the name of the original dataframe that.
872
+ :return: the new dataframe with the columns appended / overwritten.
873
+ """
874
+ pass
875
+
876
+ def inject_nodes(
877
+ self, params: dict[str, type[type]], config: dict[str, Any], fn: Callable
878
+ ) -> tuple[list[node.Node], dict[str, str]]:
879
+ namespace = fn.__name__ if self.namespace is None else self.namespace
880
+
881
+ inject_parameter, initial_nodes = self.get_initial_nodes(fn=fn, params=params)
882
+ subdag_nodes = self.get_subdag_nodes(fn=fn, config=config)
883
+ generated_nodes = initial_nodes + subdag_nodes
884
+ # TODO: for now we restrict that if user wants to change columns that already exist, he needs to
885
+ # pass the dataframe and extract them himself. If we add namespace to initial nodes and rewire the
886
+ # initial node names with the ongoing ones that have a column argument, we can also allow in place
887
+ # changes when using columns_to_pass
888
+ if with_columns_base.contains_duplicates(generated_nodes):
889
+ raise ValueError(
890
+ "You can only specify columns once. You used `columns_to_pass` and we "
891
+ "extract the columns for you. In this case they cannot be overwritten -- only new columns get "
892
+ "appended. If you want to modify in-place columns pass in a dataframe and "
893
+ "extract + modify the columns and afterwards select them."
894
+ )
895
+
896
+ pruned_nodes = prune_nodes(nodes=generated_nodes, select=self.select)
897
+ if len(pruned_nodes) == 0:
898
+ raise ValueError(
899
+ f"No nodes found upstream from select columns: {self.select} for function: "
900
+ f"{fn.__qualname__}"
901
+ )
902
+
903
+ # Node combining columns and dataframe might need info about prior nodes
904
+ output_nodes, current_param = self.chain_subdag_nodes(
905
+ fn=fn, inject_parameter=inject_parameter, generated_nodes=pruned_nodes
906
+ )
907
+ output_nodes = subdag.add_namespace(output_nodes, namespace)
908
+ return output_nodes, {inject_parameter: assign_namespace(current_param, namespace)}