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,1380 @@
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 functools
19
+ import inspect
20
+ import logging
21
+ from collections.abc import Callable, Collection
22
+ from types import CodeType, FunctionType, ModuleType
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ from pyspark.sql import SparkSession
28
+
29
+ try:
30
+ import pyspark.pandas as ps
31
+ from pyspark.sql import Column, DataFrame, dataframe, types
32
+ from pyspark.sql.functions import column, lit, pandas_udf, udf
33
+ except ImportError as e:
34
+ raise NotImplementedError("Pyspark is not installed.") from e
35
+
36
+ from hamilton import base, htypes, node
37
+ from hamilton.execution import graph_functions
38
+ from hamilton.function_modifiers import base as fm_base
39
+ from hamilton.function_modifiers import subdag
40
+ from hamilton.function_modifiers.recursive import with_columns_base
41
+ from hamilton.htypes import custom_subclass_check
42
+ from hamilton.lifecycle import base as lifecycle_base
43
+ from hamilton.plugins.pyspark_pandas_extensions import DATAFRAME_TYPE
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class KoalasDataFrameResult(base.ResultMixin):
49
+ """Mixin for building a koalas dataframe from the result"""
50
+
51
+ @staticmethod
52
+ def build_result(**outputs: dict[str, Any]) -> ps.DataFrame:
53
+ """Right now this class is just used for signaling the return type."""
54
+ pass
55
+
56
+
57
+ class SparkKoalasGraphAdapter(base.HamiltonGraphAdapter, base.ResultMixin):
58
+ """Class representing what's required to make Hamilton run on Spark with Koalas, i.e. Pandas on Spark.
59
+
60
+ This walks the graph and translates it to run onto `Apache Spark <https://spark.apache.org/">`__ \
61
+ using the \
62
+ `Pandas API on Spark <https://spark.apache.org/docs/latest/api/python/user_guide/pandas_on_spark/index.html>`__
63
+
64
+ Use `pip install sf-hamilton[spark]` to get the dependencies required to run this.
65
+
66
+ Currently, this class assumes you're running SPARK 3.2+. You'd generally use this if you have an existing spark \
67
+ cluster running in your workplace, and you want to scale to very large data set sizes.
68
+
69
+ Some tips on koalas (before it was merged into spark 3.2):
70
+
71
+ - https://databricks.com/blog/2020/03/31/10-minutes-from-pandas-to-koalas-on-apache-spark.html
72
+ - https://spark.apache.org/docs/latest/api/python/user_guide/pandas_on_spark/index.html
73
+
74
+ Spark is a more heavyweight choice to scale computation for Hamilton graphs creating a Pandas Dataframe.
75
+
76
+ Notes on scaling:
77
+ -----------------
78
+ - Multi-core on single machine ✅ (if you setup Spark locally to do so)
79
+ - Distributed computation on a Spark cluster ✅
80
+ - Scales to any size of data as permitted by Spark ✅
81
+
82
+ Function return object types supported:
83
+ ---------------------------------------
84
+ - ⛔ Not generic. This does not work for every Hamilton graph.
85
+ - ✅ Currently we're targeting this at Pandas/Koalas types [dataframes, series].
86
+
87
+ Pandas?
88
+ -------
89
+ - ✅ Koalas on Spark 3.2+ implements a good subset of the pandas API. Keep it simple and you should be good to go!
90
+
91
+ CAVEATS
92
+ -------
93
+ - Serialization costs can outweigh the benefits of parallelism, so you should benchmark your code to see if it's\
94
+ worth it.
95
+
96
+ DISCLAIMER -- this class is experimental, so signature changes are a possibility!
97
+ """
98
+
99
+ def __init__(self, spark_session, result_builder: base.ResultMixin, spine_column: str):
100
+ """Constructor
101
+
102
+ You only have the ability to return either a Pandas on Spark Dataframe or a Pandas Dataframe. To do that you \
103
+ either use the stock \
104
+ `base.PandasDataFrameResult <https://github.com/apache/hamilton/blob/main/hamilton/base.py#L39>`__ class,\
105
+ or you use `h_spark.KoalasDataframeResult <https://github.com/apache/hamilton/blob/main/hamilton/experimental/h_spark.py#L16>`__.
106
+
107
+ :param spark_session: the spark session to use.
108
+ :param result_builder: the function to build the result -- currently on Pandas and Koalas are "supported".
109
+ :param spine_column: the column we should use first as the spine and then subsequently join against.
110
+ """
111
+ self.spark_session = spark_session
112
+ if not (
113
+ isinstance(result_builder, base.PandasDataFrameResult)
114
+ or isinstance(result_builder, KoalasDataFrameResult)
115
+ or isinstance(result_builder, base.DictResult)
116
+ ):
117
+ raise ValueError(
118
+ "SparkKoalasGraphAdapter only supports returning:"
119
+ ' a "pandas" DF at the moment, a "koalas" DF at the moment, or a "dict" of results.'
120
+ )
121
+ self.result_builder = result_builder
122
+ self.spine_column = spine_column
123
+
124
+ @staticmethod
125
+ def check_input_type(node_type: type, input_value: Any) -> bool:
126
+ """Function to equate an input value, with expected node type.
127
+
128
+ We need this to equate pandas and koalas objects/types.
129
+
130
+ :param node_type: the declared node type
131
+ :param input_value: the actual input value
132
+ :return: whether this is okay, or not.
133
+ """
134
+ # TODO: flesh this out more
135
+ if (node_type == pd.Series or node_type == ps.Series) and (
136
+ isinstance(input_value, ps.DataFrame) or isinstance(input_value, ps.Series)
137
+ ):
138
+ return True
139
+ elif node_type == np.array and isinstance(input_value, dataframe.DataFrame):
140
+ return True
141
+
142
+ return htypes.check_input_type(node_type, input_value)
143
+
144
+ @staticmethod
145
+ def check_node_type_equivalence(node_type: type, input_type: type) -> bool:
146
+ """Function to help equate pandas with koalas types.
147
+
148
+ :param node_type: the declared node type.
149
+ :param input_type: the type of what we want to pass into it.
150
+ :return: whether this is okay, or not.
151
+ """
152
+ if node_type == ps.Series and input_type == pd.Series:
153
+ return True
154
+ elif node_type == pd.Series and input_type == ps.Series:
155
+ return True
156
+ elif node_type == ps.DataFrame and input_type == pd.DataFrame:
157
+ return True
158
+ elif node_type == pd.DataFrame and input_type == ps.DataFrame:
159
+ return True
160
+ return node_type == input_type
161
+
162
+ def execute_node(self, node: node.Node, kwargs: dict[str, Any]) -> Any:
163
+ """Function that is called as we walk the graph to determine how to execute a hamilton function.
164
+
165
+ :param node: the node from the graph.
166
+ :param kwargs: the arguments that should be passed to it.
167
+ :return: returns a koalas column
168
+ """
169
+ return node.callable(**kwargs)
170
+
171
+ def build_result(self, **outputs: dict[str, Any]) -> pd.DataFrame | ps.DataFrame | dict:
172
+ if isinstance(self.result_builder, base.DictResult):
173
+ return self.result_builder.build_result(**outputs)
174
+ # we don't use the actual function for building right now, we use this hacky equivalent
175
+ df = ps.DataFrame(outputs[self.spine_column])
176
+ for k, v in outputs.items():
177
+ logger.info(f"Got column {k}, with type [{type(v)}].")
178
+ df[k] = v
179
+ if isinstance(self.result_builder, base.PandasDataFrameResult):
180
+ return df.to_pandas()
181
+ else:
182
+ return df
183
+
184
+
185
+ def numpy_to_spark_type(numpy_type: type) -> types.DataType:
186
+ """Function to convert a numpy type to a Spark type.
187
+
188
+ :param numpy_type: the numpy type to convert.
189
+ :return: the Spark type.
190
+ :raise: ValueError if the type is not supported.
191
+ """
192
+ if (
193
+ numpy_type == np.int8
194
+ or numpy_type == np.int16
195
+ or numpy_type == np.int32
196
+ or numpy_type == np.int64
197
+ ):
198
+ return types.IntegerType()
199
+ elif numpy_type == np.float16 or numpy_type == np.float32 or numpy_type == np.float64:
200
+ return types.FloatType()
201
+ elif numpy_type == np.bool_:
202
+ return types.BooleanType()
203
+ elif numpy_type == np.unicode_ or numpy_type == np.string_:
204
+ return types.StringType()
205
+ elif numpy_type == np.bytes_:
206
+ return types.BinaryType()
207
+ else:
208
+ raise ValueError("Unsupported NumPy type: " + str(numpy_type))
209
+
210
+
211
+ def python_to_spark_type(python_type: type[int | float | bool | str | bytes]) -> types.DataType:
212
+ """Function to convert a Python type to a Spark type.
213
+
214
+ :param python_type: the Python type to convert.
215
+ :return: the Spark type.
216
+ :raise: ValueError if the type is not supported.
217
+ """
218
+ if python_type == int:
219
+ return types.IntegerType()
220
+ elif python_type == float:
221
+ return types.FloatType()
222
+ elif python_type == bool:
223
+ return types.BooleanType()
224
+ elif python_type == str:
225
+ return types.StringType()
226
+ elif python_type == bytes:
227
+ return types.BinaryType()
228
+ else:
229
+ raise ValueError("Unsupported Python type: " + str(python_type))
230
+
231
+
232
+ _list = (list[int], list[float], list[bool], list[str], list[bytes])
233
+
234
+
235
+ def get_spark_type(return_type: Any) -> types.DataType:
236
+ if return_type in (int, float, bool, str, bytes):
237
+ return python_to_spark_type(return_type)
238
+ elif return_type in _list:
239
+ return types.ArrayType(python_to_spark_type(return_type.__args__[0]))
240
+ elif hasattr(return_type, "__module__") and return_type.__module__ == "numpy":
241
+ return numpy_to_spark_type(return_type)
242
+ else:
243
+ raise ValueError(
244
+ f"Currently unsupported return type {return_type}. "
245
+ f"Please create an issue or PR to add support for this type."
246
+ )
247
+
248
+
249
+ def _get_pandas_annotations(node_: node.Node, bound_parameters: dict[str, Any]) -> dict[str, bool]:
250
+ """Given a function, return a dictionary of the parameters that are annotated as pandas series.
251
+
252
+ :param hamilton_udf: the function to check.
253
+ :return: dictionary of parameter names to boolean indicating if they are pandas series.
254
+ """
255
+
256
+ def _get_type_from_annotation(annotation: Any) -> Any:
257
+ """Gets the type from the annotation if there is one."""
258
+ actual_type, extras = htypes.get_type_information(annotation)
259
+ return actual_type
260
+
261
+ return {
262
+ name: _get_type_from_annotation(type_) == pd.Series
263
+ for name, (type_, dep_type) in node_.input_types.items()
264
+ if name not in bound_parameters and dep_type == node.DependencyType.REQUIRED
265
+ }
266
+
267
+
268
+ def _determine_parameters_to_bind(
269
+ actual_kwargs: dict,
270
+ df_columns: set[str],
271
+ node_input_types: dict[str, tuple],
272
+ node_name: str,
273
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
274
+ """Function that we use to bind inputs to the function, or determine we should pull them from the dataframe.
275
+
276
+ It does two things:
277
+
278
+ 1. If the parameter name matches a column name in the dataframe, create a pyspark column object for it.
279
+ 2. If the parameter name matches a key in the input dictionary, and the value is not a dataframe,\
280
+ bind it to the function.
281
+
282
+ :param actual_kwargs: the input dictionary of arguments for the function.
283
+ :param df_columns: the set of column names in the dataframe.
284
+ :param node_input_types: the input types of the function.
285
+ :param node_name: name of the node/function.
286
+ :return: a tuple of the params that come from the dataframe and the parameters to bind.
287
+ """
288
+ params_from_df = {}
289
+ bind_parameters = {}
290
+ for input_name, (type_, dep_type) in node_input_types.items(): # noqa
291
+ if input_name in df_columns:
292
+ params_from_df[input_name] = column(input_name)
293
+ elif input_name in actual_kwargs and not isinstance(actual_kwargs[input_name], DataFrame):
294
+ bind_parameters[input_name] = actual_kwargs[input_name]
295
+ elif dep_type == node.DependencyType.REQUIRED:
296
+ raise ValueError(
297
+ f"Cannot satisfy {node_name} with input types {node_input_types} against a "
298
+ f"dataframe with "
299
+ f"columns {df_columns} and input kwargs {actual_kwargs}."
300
+ )
301
+ return params_from_df, bind_parameters
302
+
303
+
304
+ def _inspect_kwargs(kwargs: dict[str, Any]) -> tuple[DataFrame, dict[str, Any]]:
305
+ """Inspects kwargs, removes any dataframes, and returns the (presumed single) dataframe, with remaining kwargs.
306
+
307
+ :param kwargs: the inputs to the function.
308
+ :return: tuple of the dataframe and the remaining non-dataframe kwargs.
309
+ """
310
+ df = None
311
+ actual_kwargs = {}
312
+ for kwarg_key, kwarg_value in kwargs.items():
313
+ if isinstance(kwarg_value, DataFrame):
314
+ if df is None:
315
+ df = kwarg_value
316
+ else:
317
+ actual_kwargs[kwarg_key] = kwarg_value
318
+ return df, actual_kwargs
319
+
320
+
321
+ def _format_pandas_udf(func_name: str, ordered_params: list[str]) -> str:
322
+ formatting_params = {
323
+ "name": func_name,
324
+ "params": ", ".join(ordered_params),
325
+ "param_call": ", ".join([f"{param}={param}" for param in ordered_params]),
326
+ }
327
+ # NOTE: we intentionally omit type annotations here. The return type is passed
328
+ # explicitly to pyspark's pandas_udf(), and parameter annotations are not needed.
329
+ # On Python 3.14+, annotations in dynamically compiled code create __annotate__
330
+ # functions (PEP 749) that break PySpark's UDF serialization.
331
+ func_string = """
332
+ def {name}({params}):
333
+ return partial_fn({param_call})
334
+ """.format(**formatting_params)
335
+ return func_string
336
+
337
+
338
+ def _format_udf(func_name: str, ordered_params: list[str]) -> str:
339
+ formatting_params = {
340
+ "name": func_name,
341
+ "params": ", ".join(ordered_params),
342
+ "param_call": ", ".join([f"{param}={param}" for param in ordered_params]),
343
+ }
344
+ func_string = """
345
+ def {name}({params}):
346
+ return partial_fn({param_call})
347
+ """.format(**formatting_params)
348
+ return func_string
349
+
350
+
351
+ def _fabricate_spark_function(
352
+ node_: node.Node,
353
+ params_to_bind: dict[str, Any],
354
+ params_from_df: dict[str, Any],
355
+ pandas_udf: bool,
356
+ ) -> FunctionType:
357
+ """Fabricates a spark compatible UDF. We have to do this as we don't actually have a funtion
358
+ with annotations to use, as its lambdas passed around by decorators. We may consider pushing
359
+ this upstreams so that everything can generate its own function, but for now this is the
360
+ easiest way to do it.
361
+
362
+ The rules are different for pandas series and regular UDFs.
363
+ Pandas series have to:
364
+ - be Decorated with pandas_udf
365
+ - Have a return type of a pandas series
366
+ - Have a pandas series as the only input types
367
+ Regular UDFs have to:
368
+ - Have no annotations at all
369
+
370
+ See https://spark.apache.org/docs/3.1.3/api/python/reference/api/pyspark.sql.functions.udf.html
371
+ and https://spark.apache.org/docs/3.1.3/api/python/reference/api/pyspark.sql.functions.pandas_udf.html
372
+
373
+ :param node_: Node to place in a spark function
374
+ :param params_to_bind: Parameters to bind to the function -- these won't go into the UDF
375
+ :param params_from_df: Parameters to retrieve from the dataframe
376
+ :return: A function that can be used in a spark UDF
377
+ """
378
+ partial_fn = functools.partial(node_.callable, **params_to_bind)
379
+ ordered_params = sorted(params_from_df)
380
+ func_name = node_.name.replace(".", "_")
381
+ if pandas_udf:
382
+ func_string = _format_pandas_udf(func_name, ordered_params)
383
+ else:
384
+ func_string = _format_udf(func_name, ordered_params)
385
+ module_code = compile(func_string, "<string>", "exec")
386
+ # Filter by name to avoid picking up __annotate__ or other helper code objects
387
+ # that Python 3.14+ may generate alongside the function (PEP 749).
388
+ func_code = [
389
+ c for c in module_code.co_consts if isinstance(c, CodeType) and c.co_name == func_name
390
+ ][0]
391
+ return FunctionType(func_code, {**globals(), **{"partial_fn": partial_fn}}, func_name)
392
+
393
+
394
+ def _lambda_udf(df: DataFrame, node_: node.Node, actual_kwargs: dict[str, Any]) -> DataFrame:
395
+ """Function to create a lambda UDF for a function.
396
+
397
+ This functions does the following:
398
+
399
+ 1. Determines whether we can bind any arguments to the function, e.g. primitives.
400
+ 2. Determines what type of UDF it is, regular or Pandas, and processes the function accordingly.
401
+ 3. Determines the return type of the UDF.
402
+ 4. Creates the UDF and applies it to the dataframe.
403
+
404
+ :param df: the spark dataframe to apply UDFs to.
405
+ :param node_: the node representing the function.
406
+ :param hamilton_udf: the function to apply.
407
+ :param actual_kwargs: the actual arguments to the function.
408
+ :return: the dataframe with one more column representing the result of the UDF.
409
+ """
410
+ params_from_df, params_to_bind = _determine_parameters_to_bind(
411
+ actual_kwargs, set(df.columns), node_.input_types, node_.name
412
+ )
413
+ pandas_annotation = _get_pandas_annotations(node_, params_to_bind)
414
+ if any(pandas_annotation.values()) and not all(pandas_annotation.values()):
415
+ raise ValueError(
416
+ f"Currently unsupported function for {node_.name} with function signature:\n{node_.input_types}."
417
+ )
418
+ elif all(pandas_annotation.values()) and len(pandas_annotation.values()) > 0:
419
+ hamilton_udf = _fabricate_spark_function(node_, params_to_bind, params_from_df, True)
420
+ # pull from annotation here instead of tag.
421
+ base_type, type_args = htypes.get_type_information(node_.type)
422
+ logger.debug("PandasUDF: %s, %s, %s", node_.name, base_type, type_args)
423
+ if not type_args:
424
+ raise ValueError(
425
+ f"{node_.name} needs to be annotated with htypes.column[pd.Series, TYPE], "
426
+ f"where TYPE could be the string name of the python type, or the python type itself."
427
+ )
428
+ type_arg = type_args[0]
429
+ if isinstance(type_arg, str):
430
+ spark_return_type = type_arg # spark will handle converting it.
431
+ else:
432
+ spark_return_type = get_spark_type(type_arg)
433
+ spark_udf = pandas_udf(hamilton_udf, spark_return_type)
434
+ else:
435
+ hamilton_udf = _fabricate_spark_function(node_, params_to_bind, params_from_df, False)
436
+ logger.debug("RegularUDF: %s, %s", node_.name, node_.type)
437
+ spark_return_type = get_spark_type(node_.type)
438
+ spark_udf = udf(hamilton_udf, spark_return_type)
439
+ out = df.withColumn(
440
+ node_.name,
441
+ spark_udf(*[_value for _name, _value in sorted(params_from_df.items())]),
442
+ )
443
+ return out
444
+
445
+
446
+ class PySparkUDFGraphAdapter(base.SimplePythonDataFrameGraphAdapter):
447
+ """UDF graph adapter for PySpark.
448
+
449
+ This graph adapter enables one to write Hamilton functions that can be executed as UDFs in PySpark.
450
+
451
+ Core to this is the mapping of function arguments to Spark columns available in the passed in dataframe.
452
+
453
+ This adapter currently supports:
454
+
455
+ - regular UDFs, these are executed in a row based fashion.
456
+ - and a single variant of Pandas UDFs: func(series+) -> series
457
+ - can also run regular Hamilton functions, which will execute spark driver side.
458
+
459
+ DISCLAIMER -- this class is experimental, so signature changes are a possibility!
460
+ """
461
+
462
+ def __init__(self):
463
+ self.df_object = None
464
+ self.original_schema = []
465
+ self.call_count = 0
466
+
467
+ @staticmethod
468
+ def check_input_type(node_type: type, input_value: Any) -> bool:
469
+ """If the input is a pyspark dataframe, skip, else delegate the check."""
470
+ if isinstance(input_value, DataFrame):
471
+ return True
472
+ return htypes.check_input_type(node_type, input_value)
473
+
474
+ @staticmethod
475
+ def check_node_type_equivalence(node_type: type, input_type: type) -> bool:
476
+ """Checks for the htype.column annotation and deals with it."""
477
+ # Good Cases:
478
+ # [pd.Series, int] -> [pd.Series, int]
479
+ # pd.series -> pd.series
480
+ # [pd.Series, int] -> int
481
+ node_base_type, node_annotations = htypes.get_type_information(node_type)
482
+ input_base_type, input_annotations = htypes.get_type_information(input_type)
483
+ exact_match = node_type == input_type
484
+ series_to_series = node_base_type == input_base_type
485
+ if node_annotations:
486
+ series_to_primitive = node_annotations[0] == input_base_type
487
+ else:
488
+ series_to_primitive = False
489
+ return exact_match or series_to_series or series_to_primitive
490
+
491
+ def execute_node(self, node: node.Node, kwargs: dict[str, Any]) -> Any:
492
+ """Given a node to execute, process it and apply a UDF if applicable.
493
+
494
+ :param node: the node we're processing.
495
+ :param kwargs: the inputs to the function.
496
+ :return: the result of the function.
497
+ """
498
+ self.call_count += 1
499
+ logger.debug("%s, %s", self.call_count, self.df_object)
500
+ # get dataframe object out of kwargs
501
+ df, actual_kwargs = _inspect_kwargs(kwargs)
502
+ if df is None: # there were no dataframes passed in. So regular function call.
503
+ return node.callable(**actual_kwargs)
504
+ if self.df_object is None:
505
+ self.df_object = df # this is done only once.
506
+ self.original_schema = list(df.columns)
507
+ logger.debug("%s, %s", self.call_count, self.df_object)
508
+ logger.debug("%s, Before, %s", node.name, self.df_object.columns)
509
+ schema_length = len(df.schema)
510
+ df = _lambda_udf(self.df_object, node, actual_kwargs)
511
+ assert node.name in df.columns, f"Error {node.name} not in {df.columns}"
512
+ delta = len(df.schema) - schema_length
513
+ if delta == 0:
514
+ raise ValueError(
515
+ f"UDF {node.name} did not add any columns to the dataframe. "
516
+ f"Does it already exist in the dataframe?"
517
+ )
518
+ self.df_object = df
519
+ logger.debug("%s, After, %s", node.name, df.columns)
520
+ return df
521
+
522
+ def build_result(self, **outputs: dict[str, Any]) -> DataFrame:
523
+ """Builds the result and brings it back to this running process.
524
+
525
+ :param outputs: the dictionary of key -> Union[ray object reference | value]
526
+ :return: The type of object returned by self.result_builder.
527
+ """
528
+ df: DataFrame = self.df_object
529
+ output_schema = self.original_schema
530
+ # what's in the dataframe:
531
+ for output_name, output_value in outputs.items():
532
+ if output_name not in output_schema:
533
+ output_schema.append(output_name)
534
+ if output_name in df.columns:
535
+ continue
536
+ else:
537
+ df = df.withColumn(output_name, lit(output_value))
538
+ # original schema + new columns should be the order.
539
+ # if someone requests a column that is in the original schema we won't duplicate it.
540
+ result = df.select(*[column(col_name) for col_name in output_schema])
541
+ # clear state out
542
+ self.df_object = None
543
+ self.original_schema = []
544
+ return result
545
+
546
+
547
+ def sparkify_node_with_udf(
548
+ node_: node.Node,
549
+ linear_df_dependency_name: str,
550
+ base_df_dependency_name: str,
551
+ base_df_dependency_param: str | None,
552
+ dependent_columns_in_group: set[str],
553
+ dependent_columns_from_dataframe: set[str],
554
+ ) -> node.Node:
555
+ """ """
556
+ """Turns a node into a spark node. This does the following:
557
+ 1. Makes it take the prior dataframe output as a dependency, in
558
+ conjunction to its current dependencies. This is so we can represent
559
+ the "logical" plan (the UDF-dependencies) as well as
560
+ the "physical plan" (linear, df operations)
561
+ 2. Adjusts the function to apply the specified UDF on the
562
+ dataframe, ignoring all inputs in column_dependencies
563
+ (which are only there to demonstrate lineage/make the DAG representative)
564
+ 3. Returns the resulting pyspark dataframe for downstream functions to use
565
+
566
+
567
+ :param node_: Node we're sparkifying
568
+ :param linear_df_dependency_name: Name of the linearly passed along dataframe dependency
569
+ :param base_df_dependency_name: Name of the base (parent) dataframe dependency.
570
+ this is only used if dependent_columns_from_dataframe is not empty
571
+ :param base_df_dendency_param: Name of the base (parent) dataframe dependency parameter, as known
572
+ by the node. This is only used if `pass_dataframe_as` is provided, which means that
573
+ dependent_columns_from_dataframe is empty.
574
+ :param dependent_columns_in_group: Columns on which this depends in the with_columns
575
+ :param dependent_columns_from_dataframe: Columns on which this depends in the
576
+ base (parent) dataframe that the with_columns is operating on
577
+ :return:
578
+
579
+ """
580
+
581
+ def new_callable(
582
+ __linear_df_dependency_name: str = linear_df_dependency_name,
583
+ __base_df_dependency_name: str = base_df_dependency_name,
584
+ __dependent_columns_in_group: set[str] = dependent_columns_in_group,
585
+ __dependent_columns_from_dataframe: set[str] = dependent_columns_from_dataframe,
586
+ __base_df_dependency_param: str = base_df_dependency_param,
587
+ __node: node.Node = node_,
588
+ **kwargs,
589
+ ) -> ps.DataFrame:
590
+ """This is the new function that the node will call.
591
+ Note that this applies the hamilton UDF with *just* the input dataframe dependency,
592
+ ignoring the rest."""
593
+ # gather the dataframe from the kwargs
594
+ df = kwargs[__linear_df_dependency_name]
595
+ kwargs = {
596
+ k: v
597
+ for k, v in kwargs.items()
598
+ if k not in __dependent_columns_from_dataframe
599
+ and k not in __dependent_columns_in_group
600
+ and k != __linear_df_dependency_name
601
+ and k != __base_df_dependency_name
602
+ }
603
+ return _lambda_udf(df, node_, kwargs)
604
+
605
+ # Just extract the dependeency type
606
+ # TODO -- add something as a "logical" or "placeholder" dependency
607
+ new_input_types = {
608
+ # copy over the old ones
609
+ **{
610
+ dep: value
611
+ for dep, value in node_.input_types.items()
612
+ if dep not in dependent_columns_from_dataframe
613
+ },
614
+ # add the new one (from the previous)
615
+ linear_df_dependency_name: (DataFrame, node.DependencyType.REQUIRED),
616
+ # Then add all the others
617
+ # Note this might clobber the linear_df_dependency_name, but they'll be the same type
618
+ # If we have "logical" dependencies we'll want to be careful about the type
619
+ **{
620
+ dep: (DataFrame, node.DependencyType.REQUIRED)
621
+ for dep, _ in node_.input_types.items()
622
+ if dep in dependent_columns_in_group
623
+ },
624
+ }
625
+
626
+ if base_df_dependency_param is not None and base_df_dependency_name in node_.input_types:
627
+ # In this case we want to add a dependency for visualization/lineage
628
+ new_input_types[base_df_dependency_name] = (
629
+ DataFrame,
630
+ node.DependencyType.REQUIRED,
631
+ )
632
+ if len(dependent_columns_from_dataframe) > 0:
633
+ new_input_types[base_df_dependency_name] = (
634
+ DataFrame,
635
+ node.DependencyType.REQUIRED,
636
+ )
637
+ return node_.copy_with(callabl=new_callable, input_types=new_input_types, typ=DataFrame)
638
+
639
+
640
+ def derive_dataframe_parameter(
641
+ param_types: dict[str, type], requested_parameter: str, location_name: Callable
642
+ ) -> str:
643
+ dataframe_parameters = {
644
+ param for param, val in param_types.items() if custom_subclass_check(val, DataFrame)
645
+ }
646
+ if requested_parameter is not None:
647
+ if requested_parameter not in dataframe_parameters:
648
+ raise ValueError(
649
+ f"Requested parameter {requested_parameter} not found in {location_name}"
650
+ )
651
+ return requested_parameter
652
+ if len(dataframe_parameters) == 0:
653
+ raise ValueError(
654
+ f"No dataframe parameters found in: {location_name}. "
655
+ f"Received parameters: {param_types}. "
656
+ f"@with_columns must inject a dataframe parameter into the function."
657
+ )
658
+ elif len(dataframe_parameters) > 1:
659
+ raise ValueError(
660
+ f"More than one dataframe parameter found in function: {location_name}. Please "
661
+ f"specify the desired one with the 'dataframe' parameter in @with_columns"
662
+ )
663
+ assert len(dataframe_parameters) == 1
664
+ return list(dataframe_parameters)[0]
665
+
666
+
667
+ def derive_dataframe_parameter_from_fn(fn: Callable, requested_parameter: str = None) -> str:
668
+ """Utility function to grab a pyspark dataframe parameter from a function.
669
+ Note if one is supplied it'll look for that. If none is, it will look to ensure
670
+ that there is only one dataframe parameter in the function.
671
+
672
+ :param fn: Function to grab the dataframe parameter from
673
+ :param requested_parameter: If supplied, the name of the parameter to grab
674
+ :return: The name of the dataframe parameter
675
+ :raises ValueError: If no datframe parameter is supplied:
676
+ - if no dataframe parameter is found, or if more than one is found
677
+ if a requested parameter is supplied:
678
+ - if the requested parameter is not found
679
+ """
680
+ sig = inspect.signature(fn)
681
+ parameters_with_types = {param.name: param.annotation for param in sig.parameters.values()}
682
+ return derive_dataframe_parameter(parameters_with_types, requested_parameter, fn.__qualname__)
683
+
684
+
685
+ def _derive_first_dataframe_parameter_from_fn(fn: Callable) -> str:
686
+ """Utility function to derive the first parameter from a function and assert
687
+ that it is annotated with a pyspark dataframe.
688
+
689
+ :param fn:
690
+ :return:
691
+ """
692
+ sig = inspect.signature(fn)
693
+ params = list(sig.parameters.items())
694
+ if len(params) == 0:
695
+ raise ValueError(
696
+ f"Function {fn.__qualname__} has no parameters, but was "
697
+ f"decorated with with_columns. with_columns requires the first "
698
+ f"parameter to be a dataframe so we know how to wire dependencies."
699
+ )
700
+ first_param_name, first_param_value = params[0]
701
+ if not custom_subclass_check(first_param_value.annotation, DataFrame):
702
+ raise ValueError(
703
+ f"Function {fn.__qualname__} has a first parameter {first_param_name} "
704
+ f"that is not a pyspark dataframe. Instead got: {first_param_value.annotation}."
705
+ f"with_columns requires the first "
706
+ f"parameter to be a dataframe so we know how to wire dependencies."
707
+ )
708
+ return first_param_name
709
+
710
+
711
+ def derive_dataframe_parameter_from_node(node_: node.Node, requested_parameter: str = None) -> str:
712
+ """Derives the only/requested dataframe parameter from a node.
713
+
714
+ :param node_:
715
+ :param requested_parameter:
716
+ :return:
717
+ """
718
+ types_ = {key: value[0] for key, value in node_.input_types.items()}
719
+ originating_function_name = (
720
+ node_.originating_functions[-1] if node_.originating_functions is not None else node_.name
721
+ )
722
+ return derive_dataframe_parameter(types_, requested_parameter, originating_function_name)
723
+
724
+
725
+ class require_columns(fm_base.NodeTransformer):
726
+ """Decorator for spark that allows for the specification of columns to transform.
727
+ These are columns within a specific node in a decorator, enabling the user to make use of pyspark
728
+ transformations inside a with_columns group. Note that this will have no impact if it is not
729
+ decorating a node inside `with_columns`.
730
+
731
+ Note that this currently does not work with other decorators, but it definitely could.
732
+ """
733
+
734
+ TRANSFORM_TARGET_TAG = "hamilton.spark.target"
735
+ TRANSFORM_COLUMNS_TAG = "hamilton.spark.columns"
736
+
737
+ def __init__(self, *columns: str):
738
+ super(require_columns, self).__init__(target=None)
739
+ self._columns = columns
740
+
741
+ def transform_node(
742
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
743
+ ) -> Collection[node.Node]:
744
+ """Generates nodes for the `@require_columns` decorator.
745
+
746
+ This does two things, but does not fully prepare the node:
747
+ 1. It adds the columns as dependencies to the node
748
+ 2. Adds tags with relevant metadata for later use
749
+
750
+ Note that, at this point, we don't actually know which columns will come from the
751
+ base dataframe, and which will come from the upstream nodes. This is handled in the
752
+ `with_columns` decorator, so for now, we need to give it enough information to topologically
753
+ sort/assign dependencies.
754
+
755
+ :param node_: Node to transform
756
+ :param config: Configuration to use (unused here)
757
+ :return:
758
+ """
759
+ param = derive_dataframe_parameter_from_node(node_)
760
+
761
+ # This allows for injection of any extra parameters
762
+ def new_callable(__input_types=node_.input_types, **kwargs):
763
+ return node_.callable(
764
+ **{key: value for key, value in kwargs.items() if key in __input_types}
765
+ )
766
+
767
+ additional_input_types = {
768
+ param: (DataFrame, node.DependencyType.REQUIRED)
769
+ for param in self._columns
770
+ if param not in node_.input_types
771
+ }
772
+ node_out = node_.copy_with(
773
+ input_types={**node_.input_types, **additional_input_types},
774
+ callabl=new_callable,
775
+ tags={
776
+ require_columns.TRANSFORM_TARGET_TAG: param,
777
+ require_columns.TRANSFORM_COLUMNS_TAG: self._columns,
778
+ },
779
+ )
780
+ # if it returns a column, we just turn it into a withColumn expression
781
+ if custom_subclass_check(node_.type, Column):
782
+
783
+ def transform_output(output: Column, kwargs: dict[str, Any]) -> DataFrame:
784
+ return kwargs[param].withColumn(node_.name, output)
785
+
786
+ node_out = node_out.transform_output(transform_output, DataFrame)
787
+ return [node_out]
788
+
789
+ def validate(self, fn: Callable):
790
+ """Validates on the function, even though it operates on nodes. We can always loosen
791
+ this, but for now it should help the code stay readable.
792
+
793
+ :param fn: Function this is decorating
794
+ :return:
795
+ """
796
+
797
+ _derive_first_dataframe_parameter_from_fn(fn)
798
+
799
+ @staticmethod
800
+ def _extract_dataframe_params(node_: node.Node) -> list[str]:
801
+ """Extracts the dataframe parameters from a node.
802
+
803
+ :param node_: Node to extract from
804
+ :return: List of dataframe parameters
805
+ """
806
+ return [
807
+ key
808
+ for key, value in node_.input_types.items()
809
+ if custom_subclass_check(value[0], DataFrame)
810
+ ]
811
+
812
+ @staticmethod
813
+ def is_default_pyspark_udf(node_: node.Node) -> bool:
814
+ """Tells if a node is, by default, a pyspark UDF. This means:
815
+ 1. It has a single dataframe parameter
816
+ 2. That parameter name determines an upstream column name
817
+
818
+ :param node_: Node to check
819
+ :return: True if it functions as a default pyspark UDF, false otherwise
820
+ """
821
+ df_columns = require_columns._extract_dataframe_params(node_)
822
+ return len(df_columns) == 1
823
+
824
+ @staticmethod
825
+ def is_decorated_pyspark_udf(node_: node.Node):
826
+ """Tells if this is a decorated pyspark UDF. This means it has been
827
+ decorated by the `@transforms` decorator.
828
+
829
+ :return: True if it can be run as part of a group, false otherwise
830
+ """
831
+ if "hamilton.spark.columns" in node_.tags and "hamilton.spark.target" in node_.tags:
832
+ return True
833
+ return False
834
+
835
+ @staticmethod
836
+ def sparkify_node(
837
+ node_: node.Node,
838
+ linear_df_dependency_name: str,
839
+ base_df_dependency_name: str,
840
+ base_df_param_name: str | None,
841
+ dependent_columns_from_upstream: set[str],
842
+ dependent_columns_from_dataframe: set[str],
843
+ ) -> node.Node:
844
+ """Transforms a pyspark node into a node that can be run as part of a `with_columns` group.
845
+ This is only for non-UDF nodes that have already been transformed by `@transforms`.
846
+
847
+ :param node_: Node to transform
848
+ :param linear_df_dependency_name: Dependency on continaully modified dataframe (this will enable us
849
+ :param base_df_dependency_name:
850
+ :param dependent_columns_in_group:
851
+ :param dependent_columns_from_dataframe:
852
+ :return: The final node with correct dependencies
853
+ """
854
+ transformation_target = node_.tags.get(require_columns.TRANSFORM_TARGET_TAG)
855
+
856
+ # Note that the following does not use the reassign_columns function as we have
857
+ # special knowledge of the function -- E.G. that it doesn't need all the parameters
858
+ # we choose to pass it. Thus we can just make sure that we pass it the right one,
859
+ # and not worry about value-clashes in reassigning names (as there are all sorts of
860
+ # edge cases around the parameter name to be transformed).
861
+
862
+ # We have only a few dependencies we truly need
863
+ # These are the linear_df_dependency_name (the dataframe that is being modified)
864
+ # as well as any non-dataframe arguments (E.G. the ones that aren't about to be added
865
+ # Note that the node comes with logical dependencies already, so we filter them out
866
+ def new_callable(__callable=node_.callable, **kwargs) -> Any:
867
+ new_kwargs = kwargs.copy()
868
+ new_kwargs[transformation_target] = kwargs[linear_df_dependency_name]
869
+ return __callable(**new_kwargs)
870
+
871
+ # We start off with everything except the transformation target, as we're
872
+ # going to use the linear dependency for that (see the callable above)
873
+ new_input_types = {
874
+ key: value
875
+ for key, value in node_.input_types.items()
876
+ if key != transformation_target and key not in dependent_columns_from_dataframe
877
+ }
878
+ # Thus we put that linear dependency in
879
+ new_input_types[linear_df_dependency_name] = (
880
+ DataFrame,
881
+ node.DependencyType.REQUIRED,
882
+ )
883
+ # Then we go through all "logical" dependencies -- columns we want to add to make lineage
884
+ # look nice
885
+ for item in dependent_columns_from_upstream:
886
+ new_input_types[item] = (DataFrame, node.DependencyType.REQUIRED)
887
+
888
+ # Then we see if we're trying to transform the base dataframe
889
+ # This means we're not referring to it as a column, and only happens with the
890
+ # `pass_dataframe_as` argument (which means the base_df_param_name is not None)
891
+ if transformation_target == base_df_param_name:
892
+ new_input_types[base_df_dependency_name] = (
893
+ DataFrame,
894
+ node.DependencyType.REQUIRED,
895
+ )
896
+ # Finally we create the new node and return it
897
+ node_ = node_.copy_with(callabl=new_callable, input_types=new_input_types)
898
+ return node_
899
+
900
+
901
+ def _identify_upstream_dataframe_nodes(nodes: list[node.Node]) -> list[str]:
902
+ """Gives the upstream dataframe name. This is the only ps.DataFrame parameter not
903
+ produced from within the subdag.
904
+
905
+ :param nodes: Nodes in the subdag
906
+ :return: The name of the upstream dataframe
907
+ """
908
+ node_names = {node_.name for node_ in nodes}
909
+ df_deps = set()
910
+
911
+ for node_ in nodes:
912
+ # In this case its a df node that is a linear dependency, so we don't count it
913
+ # Instead we count the columns it wants, as we have not yet created them TODO --
914
+ # consider moving this validation afterwards so we don't have to do this check
915
+ df_dependencies = node_.tags.get(
916
+ require_columns.TRANSFORM_COLUMNS_TAG,
917
+ [
918
+ dep
919
+ for dep, (type_, _) in node_.input_types.items()
920
+ if custom_subclass_check(type_, DataFrame)
921
+ ],
922
+ )
923
+ for dependency in df_dependencies:
924
+ if dependency not in node_names:
925
+ df_deps.add(dependency)
926
+ return list(df_deps)
927
+
928
+
929
+ class with_columns(with_columns_base):
930
+ def __init__(
931
+ self,
932
+ *load_from: Callable | ModuleType,
933
+ columns_to_pass: list[str] = None,
934
+ pass_dataframe_as: str = None,
935
+ on_input: str = None,
936
+ select: list[str] = None,
937
+ namespace: str = None,
938
+ mode: str = "append",
939
+ config_required: list[str] = None,
940
+ ):
941
+ """Initializes a with_columns decorator for spark. This allows you to efficiently run
942
+ groups of map operations on a dataframe, represented as pandas/primitives UDFs. This
943
+ effectively "linearizes" compute -- meaning that a DAG of map operations can be run
944
+ as a set of `.withColumn` operations on a single dataframe -- ensuring that you don't have
945
+ to do a complex `extract` then `join` process on spark, which can be inefficient.
946
+
947
+ Here's an example of calling it -- if you've seen `@subdag`, you should be familiar with
948
+ the concepts:
949
+
950
+ .. code-block:: python
951
+
952
+ # my_module.py
953
+ def a(a_from_df: pd.Series) -> pd.Series:
954
+ return _process(a)
955
+
956
+ def b(b_from_df: pd.Series) -> pd.Series:
957
+ return _process(b)
958
+
959
+ def a_plus_b(a_from_df: pd.Series, b_from_df: pd.Series) -> pd.Series:
960
+ return a + b
961
+
962
+
963
+ # the with_columns call
964
+ @with_columns(
965
+ load_from=[my_module], # Load from any module
966
+ columns_to_pass=["a_from_df", "b_from_df"], # The columns to pass from the dataframe to
967
+ # the subdag
968
+ select=["a", "b", "a_plus_b"], # The columns to select from the dataframe
969
+ )
970
+ def final_df(initial_df: ps.DataFrame) -> ps.DataFrame:
971
+ # process, or just return unprocessed
972
+ ...
973
+
974
+ You can think of the above as a series of withColumn calls on the dataframe, where the
975
+ operations are applied in topological order. This is significantly more efficient than
976
+ extracting out the columns, applying the maps, then joining, but *also* allows you to
977
+ express the operations individually, making it easy to unit-test and reuse.
978
+
979
+ Note that the operation is "append", meaning that the columns that are selected are appended
980
+ onto the dataframe. We will likely add an option to have this be either "select" or "append"
981
+ mode.
982
+
983
+ If the function takes multiple dataframes, the dataframe input to process will always be
984
+ the first one. This will be passed to the subdag, transformed, and passed back to the functions.
985
+ This follows the hamilton rule of reference by parameter name. To demonstarte this, in the code
986
+ above, the dataframe that is passed to the subdag is `initial_df`. That is transformed
987
+ by the subdag, and then returned as the final dataframe.
988
+
989
+ You can read it as:
990
+
991
+ "final_df is a function that transforms the upstream dataframe initial_df, running the transformations
992
+ from my_module. It starts with the columns a_from_df and b_from_df, and then adds the columns
993
+ a, b, and a_plus_b to the dataframe. It then returns the dataframe, and does some processing on it."
994
+
995
+
996
+ :param load_from: The functions that will be used to generate the group of map operations.
997
+ :param columns_to_pass: The initial schema of the dataframe. This is used to determine which
998
+ upstream inputs should be taken from the dataframe, and which shouldn't. Note that, if this is
999
+ left empty (and external_inputs is as well), we will assume that all dependencies come
1000
+ from the dataframe. This cannot be used in conjunction with pass_dataframe_as.
1001
+ :param pass_dataframe_as: The name of the dataframe that we're modifying, as known to the subdag.
1002
+ If you pass this in, you are responsible for extracting columns out. If not provided, you have
1003
+ to pass columns_to_pass in, and we will extract the columns out for you.
1004
+ :param select: Outputs to select from the subdag, i.e. functions/module passed int. If this is left
1005
+ blank it will add all possible columns from the subdag to the dataframe.
1006
+ :param namespace: The namespace of the nodes, so they don't clash with the global namespace
1007
+ and so this can be reused. If its left out, there will be no namespace (in which case you'll want
1008
+ to be careful about repeating it/reusing the nodes in other parts of the DAG.)
1009
+ :param mode: The mode of the operation. This can be either "append" or "select".
1010
+ If it is "append", it will keep all original columns in the dataframe, and append what's in select.
1011
+ If it is "select", it will do a global select of columns in the dataframe from the `select` parameter.
1012
+ Note that, if the `select` parameter is left blank, it will add all columns in the dataframe
1013
+ that are in the subdag. This defaults to `append`. If you're using select, use the `@select` decorator
1014
+ instead.
1015
+ :param config_required: the list of config keys that are required to resolve any functions. Pass in None\
1016
+ if you want the functions/modules to have access to all possible config.
1017
+ """
1018
+
1019
+ if on_input is not None:
1020
+ raise NotImplementedError(
1021
+ "We currently do not support on_input for spark. Please reach out if you need this "
1022
+ "functionality."
1023
+ )
1024
+
1025
+ super().__init__(
1026
+ *load_from,
1027
+ columns_to_pass=columns_to_pass,
1028
+ pass_dataframe_as=pass_dataframe_as,
1029
+ select=select,
1030
+ namespace=namespace,
1031
+ config_required=config_required,
1032
+ dataframe_type=DATAFRAME_TYPE,
1033
+ )
1034
+
1035
+ self.mode = mode
1036
+
1037
+ @staticmethod
1038
+ def _prep_nodes(initial_nodes: list[node.Node]) -> list[node.Node]:
1039
+ """Prepares nodes by decorating "default" UDFs with transform.
1040
+ This allows us to use the sparkify_node function in transforms
1041
+ for both the default ones and the decorated ones.
1042
+
1043
+ :param initial_nodes: Initial nodes to prepare
1044
+ :return: Prepared nodes
1045
+ """
1046
+ out = []
1047
+ for node_ in initial_nodes:
1048
+ if require_columns.is_default_pyspark_udf(node_):
1049
+ col = derive_dataframe_parameter_from_node(node_)
1050
+ # todo -- wire through config/function correctly
1051
+ # the col is the only dataframe paameter so it is the target node
1052
+ (node_,) = require_columns(col).transform_node(node_, {}, node_.callable)
1053
+ out.append(node_)
1054
+ return out
1055
+
1056
+ @staticmethod
1057
+ def create_selector_node(
1058
+ upstream_name: str, columns: list[str], node_name: str = "select"
1059
+ ) -> node.Node:
1060
+ """Creates a selector node. The sole job of this is to select just the specified columns.
1061
+ Note this is a utility function that's only called here.
1062
+
1063
+ :param upstream_name: Name of the upstream dataframe node
1064
+ :param columns: Columns to select
1065
+ :param node_name: Name of the node to create
1066
+ :return:
1067
+ """
1068
+
1069
+ def new_callable(**kwargs) -> DataFrame:
1070
+ return kwargs[upstream_name].select(*columns)
1071
+
1072
+ return node.Node(
1073
+ name=node_name,
1074
+ typ=DataFrame,
1075
+ callabl=new_callable,
1076
+ input_types={upstream_name: DataFrame},
1077
+ )
1078
+
1079
+ @staticmethod
1080
+ def create_drop_node(
1081
+ upstream_name: str, columns: list[str], node_name: str = "select"
1082
+ ) -> node.Node:
1083
+ """Creates a drop node. The sole job of this is to drop just the specified columns.
1084
+ Note this is a utility function that's only called here.
1085
+
1086
+ :param upstream_name: Name of the upstream dataframe node
1087
+ :param columns: Columns to drop
1088
+ :param node_name: Name of the node to create
1089
+ :return:
1090
+ """
1091
+
1092
+ def new_callable(**kwargs) -> DataFrame:
1093
+ return kwargs[upstream_name].drop(*columns)
1094
+
1095
+ return node.Node(
1096
+ name=node_name,
1097
+ typ=DataFrame,
1098
+ callabl=new_callable,
1099
+ input_types={upstream_name: DataFrame},
1100
+ )
1101
+
1102
+ def _validate_dataframe_subdag_parameter(self, nodes: list[node.Node], fn_name: str):
1103
+ all_upstream_dataframe_nodes = _identify_upstream_dataframe_nodes(nodes)
1104
+ initial_schema = set(self.initial_schema) if self.initial_schema is not None else set()
1105
+ candidates_for_upstream_dataframe = set(all_upstream_dataframe_nodes) - set(initial_schema)
1106
+ if (
1107
+ len(candidates_for_upstream_dataframe) > 1
1108
+ or self.dataframe_subdag_param is None
1109
+ and len(candidates_for_upstream_dataframe) > 0
1110
+ ):
1111
+ raise ValueError(
1112
+ f"We found multiple upstream dataframe parameters for function: {fn_name} decorated with "
1113
+ f"@with_columns. You specified pass_dataframe_as={self.dataframe_subdag_param} as the upstream "
1114
+ f"dataframe parameter, which means that your subdag must have exactly {0 if self.dataframe_subdag_param is None else 1} "
1115
+ f"upstream dataframe parameters. Instead, we found the following upstream dataframe parameters: {candidates_for_upstream_dataframe}"
1116
+ )
1117
+ if self.dataframe_subdag_param is not None:
1118
+ if len(candidates_for_upstream_dataframe) == 0:
1119
+ raise ValueError(
1120
+ f"You specified your set of UDFs to use upstream dataframe parameter: {self.dataframe_subdag_param} "
1121
+ f"for function: {fn_name} decorated with `with_columns`, but we could not find "
1122
+ "that parameter as a dependency of any of the nodes. Note that that dependency "
1123
+ "must be a pyspark dataframe. If you wish, instead, to supply an initial set of "
1124
+ "columns for the upstream dataframe and refer to those columns directly within "
1125
+ "your UDFs, please use columns_to_pass instead of pass_dataframe_as."
1126
+ )
1127
+ (upstream_dependency,) = list(candidates_for_upstream_dataframe)
1128
+ if upstream_dependency != self.dataframe_subdag_param:
1129
+ raise ValueError(
1130
+ f"You specified your set of UDFs to use upstream dataframe parameter: {self.dataframe_subdag_param} "
1131
+ f"for function: {fn_name} decorated with `with_columns`, but we found that parameter "
1132
+ f"as a dependency of a node, but it was not the same as the parameter you specified. "
1133
+ f"Instead, we found: {upstream_dependency}."
1134
+ )
1135
+
1136
+ def required_config(self) -> list[str]:
1137
+ return self.config_required
1138
+
1139
+ def get_initial_nodes(
1140
+ self, fn: Callable, params: dict[str, type[type]]
1141
+ ) -> tuple[str, Collection[node.Node]]:
1142
+ inject_parameter = _derive_first_dataframe_parameter_from_fn(fn=fn)
1143
+ with_columns_base.validate_dataframe(
1144
+ fn=fn,
1145
+ inject_parameter=inject_parameter,
1146
+ params=params,
1147
+ required_type=self.dataframe_type,
1148
+ )
1149
+ # Cannot extract columns in pyspark
1150
+ initial_nodes = []
1151
+ return inject_parameter, initial_nodes
1152
+
1153
+ def get_subdag_nodes(self, fn: Callable, config: dict[str, Any]) -> Collection[node.Node]:
1154
+ initial_nodes = subdag.collect_nodes(config, self.subdag_functions)
1155
+ transformed_nodes = with_columns._prep_nodes(initial_nodes)
1156
+
1157
+ self._validate_dataframe_subdag_parameter(transformed_nodes, fn.__qualname__)
1158
+ return transformed_nodes
1159
+
1160
+ def chain_subdag_nodes(
1161
+ self, fn: Callable, inject_parameter: str, generated_nodes: Collection[node.Node]
1162
+ ) -> node.Node:
1163
+ generated_nodes = graph_functions.topologically_sort_nodes(generated_nodes)
1164
+
1165
+ # Columns that it is dependent on could be from the group of transforms created
1166
+ columns_produced_within_mapgroup = {node_.name for node_ in generated_nodes}
1167
+ # Or from the dataframe passed in...
1168
+ columns_passed_in_from_dataframe = (
1169
+ set(self.initial_schema) if self.initial_schema is not None else []
1170
+ )
1171
+
1172
+ current_dataframe_node = inject_parameter
1173
+ output_nodes = []
1174
+ drop_list = []
1175
+ for node_ in generated_nodes:
1176
+ # dependent columns are broken into two sets:
1177
+ # 1. Those that come from the group of transforms
1178
+ dependent_columns_in_mapgroup = {
1179
+ column for column in node_.input_types if column in columns_produced_within_mapgroup
1180
+ }
1181
+ # 2. Those that come from the dataframe
1182
+ dependent_columns_in_dataframe = {
1183
+ column for column in node_.input_types if column in columns_passed_in_from_dataframe
1184
+ }
1185
+ # In the case that we are using pyspark UDFs
1186
+ if require_columns.is_decorated_pyspark_udf(node_):
1187
+ sparkified = require_columns.sparkify_node(
1188
+ node_,
1189
+ current_dataframe_node,
1190
+ inject_parameter,
1191
+ self.dataframe_subdag_param,
1192
+ dependent_columns_in_mapgroup,
1193
+ dependent_columns_in_dataframe,
1194
+ )
1195
+ # otherwise we're using pandas/primitive UDFs
1196
+ else:
1197
+ sparkified = sparkify_node_with_udf(
1198
+ node_,
1199
+ current_dataframe_node,
1200
+ inject_parameter,
1201
+ self.dataframe_subdag_param,
1202
+ dependent_columns_in_mapgroup,
1203
+ dependent_columns_in_dataframe,
1204
+ )
1205
+
1206
+ if self.select is not None and sparkified.name not in self.select:
1207
+ # we need to create a drop list because we don't want to drop
1208
+ # original columns from the DF by accident.
1209
+ drop_list.append(sparkified.name)
1210
+
1211
+ output_nodes.append(sparkified)
1212
+ current_dataframe_node = sparkified.name
1213
+
1214
+ if self.mode == "select":
1215
+ # Have to redo this here since for spark the nodes are of type dataframe and not columns
1216
+ # so with_columns.inject_nodes does not correctly select all the sink nodes
1217
+ select_columns = (
1218
+ self.select if self.select is not None else [item.name for item in generated_nodes]
1219
+ )
1220
+ select_node = with_columns.create_selector_node(
1221
+ upstream_name=current_dataframe_node,
1222
+ columns=select_columns,
1223
+ node_name="_select",
1224
+ )
1225
+ output_nodes.append(select_node)
1226
+ current_dataframe_node = select_node.name
1227
+ elif self.select is not None and len(drop_list) > 0:
1228
+ # since it's in append mode, we only want to append what's in the select
1229
+ # but we don't know what the original schema is, so we instead drop
1230
+ # things from the DF to achieve the same result
1231
+ select_node = with_columns.create_drop_node(
1232
+ upstream_name=current_dataframe_node,
1233
+ columns=drop_list,
1234
+ node_name="_select",
1235
+ )
1236
+ output_nodes.append(select_node)
1237
+ current_dataframe_node = select_node.name
1238
+
1239
+ return output_nodes, current_dataframe_node
1240
+
1241
+ def validate(self, fn: Callable):
1242
+ _derive_first_dataframe_parameter_from_fn(fn)
1243
+
1244
+
1245
+ class select(with_columns):
1246
+ def __init__(
1247
+ self,
1248
+ *load_from: Callable | ModuleType,
1249
+ columns_to_pass: list[str] = None,
1250
+ pass_dataframe_as: str = None,
1251
+ output_cols: list[str] = None,
1252
+ namespace: str = None,
1253
+ config_required: list[str] = None,
1254
+ ):
1255
+ """Initializes a select decorator for spark. This allows you to efficiently run
1256
+ groups of map operations on a dataframe, represented as pandas/primitives UDFs. This
1257
+ effectively "linearizes" compute -- meaning that a DAG of map operations can be run
1258
+ as a set of `.select` operations on a single dataframe -- ensuring that you don't have
1259
+ to do a complex `extract` then `join` process on spark, which can be inefficient.
1260
+
1261
+ Here's an example of calling it -- if you've seen `@subdag`, you should be familiar with
1262
+ the concepts:
1263
+
1264
+ .. code-block:: python
1265
+
1266
+ # my_module.py
1267
+ def a(a_from_df: pd.Series) -> pd.Series:
1268
+ return _process(a)
1269
+
1270
+ def b(b_from_df: pd.Series) -> pd.Series:
1271
+ return _process(b)
1272
+
1273
+ def a_plus_b(a_from_df: pd.Series, b_from_df: pd.Series) -> pd.Series:
1274
+ return a + b
1275
+
1276
+
1277
+ # the with_columns call
1278
+ @select(
1279
+ load_from=[my_module], # Load from any module
1280
+ columns_to_pass=["a_from_df", "b_from_df"], # The columns to pass from original dataframe to
1281
+ output_cols=["a", "b", "a_plus_b"], # The columns to have in the final dataframe
1282
+ )
1283
+ def final_df(initial_df: ps.DataFrame) -> ps.DataFrame:
1284
+ # process, or just return unprocessed
1285
+ ...
1286
+
1287
+ You can think of the above as a series of select/withColumns calls on the dataframe, where the
1288
+ operations are applied in topological order. This is significantly more efficient than
1289
+ extracting out the columns, applying the maps, then joining, but *also* allows you to
1290
+ express the operations individually, making it easy to unit-test and reuse.
1291
+
1292
+ Note that the operation is "append", meaning that the columns that are selected are appended
1293
+ onto the dataframe, and then at the end only what is request is selected.
1294
+
1295
+ If the function takes multiple dataframes, the dataframe input to process will always be
1296
+ the first one. This will be passed to the subdag, transformed, and passed back to the functions.
1297
+ This follows the hamilton rule of reference by parameter name. To demonstarte this, in the code
1298
+ above, the dataframe that is passed to the subdag is `initial_df`. That is transformed
1299
+ by the subdag, and then returned as the final dataframe.
1300
+
1301
+ You can read it as:
1302
+
1303
+ "final_df is a function that transforms the upstream dataframe initial_df, running the transformations
1304
+ from my_module. It starts with the columns a_from_df and b_from_df, and then adds the columns
1305
+ a, b, and a_plus_b to the dataframe. It then returns the dataframe, and does some processing on it."
1306
+
1307
+
1308
+ :param load_from: The functions that will be used to generate the group of map operations.
1309
+ :param columns_to_pass: The initial schema of the dataframe. This is used to determine which
1310
+ upstream inputs should be taken from the dataframe, and which shouldn't. Note that, if this is
1311
+ left empty (and external_inputs is as well), we will assume that all dependencies come
1312
+ from the dataframe. This cannot be used in conjunction with pass_dataframe_as.
1313
+ :param pass_dataframe_as: The name of the dataframe that we're modifying, as known to the subdag.
1314
+ If you pass this in, you are responsible for extracting columns out. If not provided, you have
1315
+ to pass columns_to_pass in, and we will extract the columns out for you.
1316
+ :param output_cols: Columns to select in the final dataframe. If this is left blank it will
1317
+ add all possible columns from the subdag to the dataframe.
1318
+ :param namespace: The namespace of the nodes, so they don't clash with the global namespace
1319
+ and so this can be reused. If its left out, there will be no namespace (in which case you'll want
1320
+ to be careful about repeating it/reusing the nodes in other parts of the DAG.)
1321
+ :param config_required: the list of config keys that are required to resolve any functions. Pass in None\
1322
+ if you want the functions/modules to have access to all possible config.
1323
+ """
1324
+ super(select, self).__init__(
1325
+ *load_from,
1326
+ columns_to_pass=columns_to_pass,
1327
+ pass_dataframe_as=pass_dataframe_as,
1328
+ select=output_cols,
1329
+ namespace=namespace,
1330
+ mode="select",
1331
+ config_required=config_required,
1332
+ )
1333
+
1334
+
1335
+ class SparkInputValidator(lifecycle_base.BaseDoValidateInput):
1336
+ """This is a graph hook adapter that allows you to get past a <4.0.0 limitation in spark.
1337
+ Spark has the option to choose between spark connect and spark, which largely have the same API.
1338
+ That said, they don't have the proper subclass relationships, which make hamilton fail on the input type checking.
1339
+
1340
+ See the following for more information as to why this is necessary:
1341
+ - https://community.databricks.com/t5/data-engineering/pyspark-sql-connect-dataframe-dataframe-vs-pyspark-sql-dataframe/td-p/71055
1342
+ - https://issues.apache.org/jira/browse/SPARK-47909
1343
+
1344
+ You can access an instance of this through the convenience variable `SPARK_INPUT_CHECK`.
1345
+ This allows you to bypass that. This has to be used with the driver builder pattern -- this will look as follows:
1346
+
1347
+ .. code-block:: python
1348
+
1349
+ from hamilton import driver
1350
+ from hamilton.plugins import h_spark
1351
+
1352
+ dr = driver.Builder().with_modules(...).with_adapters(h_spark.SPARK_INPUT_CHECK).build()
1353
+
1354
+ Then run it as you would normally. Note that in spark==4.0.0, you will only need the spark session check,
1355
+ not the dataframe check.
1356
+ """
1357
+
1358
+ def do_validate_input(self, *, node_type: type, input_value: Any) -> bool:
1359
+ """Validates the input. Treats connect/classic sessios/dataframe as interchangeable."""
1360
+
1361
+ try:
1362
+ from pyspark.sql.connect.dataframe import DataFrame as CDataFrame
1363
+ from pyspark.sql.connect.session import SparkSession as CSparkSession
1364
+ except ImportError as e:
1365
+ raise ImportError(
1366
+ "You need to have the spark-connect package installed to use this adapter. That said,"
1367
+ "you really shouldn't need this adapter if you don't have it installed (as you won't be able to "
1368
+ "get a spark connect session/dataframe without it...). Probably best to remove this"
1369
+ "adapter all together, but if you need to you can run pip install 'pyspark[connect]'"
1370
+ ) from e
1371
+ node_type_is_df = isinstance(input_value, (CDataFrame, DataFrame))
1372
+ if node_type_is_df and issubclass(node_type, (DataFrame, CDataFrame)):
1373
+ return True
1374
+ node_type_is_spark_session = isinstance(input_value, (CSparkSession, SparkSession))
1375
+ if node_type_is_spark_session and issubclass(node_type, (CSparkSession, SparkSession)):
1376
+ return True
1377
+ return htypes.check_input_type(node_type, input_value)
1378
+
1379
+
1380
+ SPARK_INPUT_CHECK = SparkInputValidator()