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,1230 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ import collections
19
+ import dataclasses
20
+ import functools
21
+ import inspect
22
+ import typing
23
+ from collections.abc import Callable, Collection
24
+ from typing import Any
25
+
26
+ import typing_extensions
27
+ import typing_inspect
28
+
29
+ from hamilton import htypes, node, registry
30
+ from hamilton.dev_utils import deprecation
31
+ from hamilton.function_modifiers import base
32
+ from hamilton.function_modifiers.dependencies import (
33
+ ParametrizedDependency,
34
+ ParametrizedDependencySource,
35
+ source,
36
+ value,
37
+ )
38
+
39
+ try:
40
+ from typing import override
41
+ except ImportError:
42
+ override = lambda x: x # noqa E731
43
+
44
+ """Decorators that enables DRY code by expanding one node into many"""
45
+
46
+
47
+ class parameterize(base.NodeExpander):
48
+ """Decorator to use to create many functions.
49
+
50
+ Expands a single function into n, each of which correspond to a function in which the parameter value is replaced\
51
+ either by:
52
+
53
+ #. A specified literal value, denoted value('literal_value').
54
+ #. The output from a specified upstream function (i.e. node), denoted source('upstream_function_name').
55
+
56
+ Note that ``parameterize`` can take the place of ``@parameterize_sources`` or ``@parameterize_values`` decorators \
57
+ below. In fact, they delegate to this!
58
+
59
+ Examples expressing different syntax:
60
+
61
+ .. code-block:: python
62
+
63
+ @parameterize(
64
+ # tuple of assignments (consisting of literals/upstream specifications), and docstring.
65
+ replace_no_parameters=({}, 'fn with no parameters replaced'),
66
+ )
67
+ def no_param_function() -> Any:
68
+ ...
69
+
70
+ @parameterize(
71
+ # tuple of assignments (consisting of literals/upstream specifications), and docstring.
72
+ replace_just_upstream_parameter=(
73
+ {'upstream_source': source('foo_source')},
74
+ 'fn with upstream_parameter set to node foo'
75
+ ),
76
+ )
77
+ def param_is_upstream_function(upstream_source: Any) -> Any:
78
+ '''Doc string that can also be parameterized: {upstream_source}.'''
79
+ ...
80
+
81
+ @parameterize(
82
+ replace_just_literal_parameter={'literal_parameter': value('bar')},
83
+ )
84
+ def param_is_literal_value(literal_parameter: Any) -> Any:
85
+ '''Doc string that can also be parameterized: {literal_parameter}.'''
86
+ ...
87
+
88
+ @parameterize(
89
+ replace_both_parameters={
90
+ 'upstream_parameter': source('foo_source'),
91
+ 'literal_parameter': value('bar')
92
+ }
93
+ )
94
+ def concat(upstream_parameter: Any, literal_parameter: str) -> Any:
95
+ '''Adding {literal_parameter} to {upstream_parameter} to create {output_name}.'''
96
+ return upstream_parameter + literal_parameter
97
+
98
+ You also have the capability to "group" parameters, which will combine them into a list.
99
+
100
+ .. code-block:: python
101
+
102
+ @parameterize(
103
+ a_plus_b_plus_c={
104
+ 'to_concat' : group(source('a'), value('b'), source('c'))
105
+ }
106
+ )
107
+ def concat(to_concat: List[str]) -> Any:
108
+ '''Adding {literal_parameter} to {upstream_parameter} to create {output_name}.'''
109
+ return sum(to_concat, '')
110
+ """
111
+
112
+ RESERVED_KWARG = "output_name"
113
+ # This is a kwarg that replaces it with the name of the function
114
+ # Double underscore means it will not be provided as user-base kwargs
115
+ # as hamilton is not OK with these output names
116
+ # We need this as we need to know the name of the function
117
+ # for the `@inject` usage but its not provided at
118
+ # construction time, so we provide a placeholder
119
+ PLACEHOLDER_PARAM_NAME = "__<function_name>"
120
+
121
+ def __init__(
122
+ self,
123
+ **parametrization: dict[str, ParametrizedDependency]
124
+ | tuple[dict[str, ParametrizedDependency], str],
125
+ ):
126
+ """Decorator to use to create many functions.
127
+
128
+ :param parametrization: `**kwargs` with one of two things:
129
+
130
+ - a tuple of assignments (consisting of literals/upstream specifications), and docstring.
131
+ - just assignments, in which case it parametrizes the existing docstring.
132
+ """
133
+ self.parameterization = {
134
+ key: (value[0] if isinstance(value, tuple) else value)
135
+ for key, value in parametrization.items()
136
+ }
137
+ bad_values = []
138
+ for _assigned_output, mapping in self.parameterization.items():
139
+ for _parameter, val in mapping.items():
140
+ if not isinstance(val, ParametrizedDependency):
141
+ bad_values.append(val)
142
+ if bad_values:
143
+ raise base.InvalidDecoratorException(
144
+ f"@parameterize must specify a dependency type -- either source() or value()."
145
+ f"The following are not allowed: {bad_values}."
146
+ )
147
+ self.specified_docstrings = {
148
+ key: value[1] for key, value in parametrization.items() if isinstance(value, tuple)
149
+ }
150
+
151
+ def split_parameterizations(
152
+ self, parameterizations: dict[str, ParametrizedDependency]
153
+ ) -> dict[ParametrizedDependencySource, dict[str, ParametrizedDependency]]:
154
+ """Split parameterizations into two groups: those that are literal values, and those that are upstream nodes.
155
+ Will have a key for each existing dependency type.
156
+
157
+ :param parameterizations: Passed into @parameterize
158
+ :return: The parameterizations grouped by dependency type
159
+ """
160
+ out = collections.defaultdict(dict)
161
+ for param_name, replacement in parameterizations.items():
162
+ out[replacement.get_dependency_type()][param_name] = replacement
163
+ return out
164
+
165
+ def _get_grouped_list_name(self, index: int, arg_name: str):
166
+ """Gets the name of the arg for a given index in a list of args, using grouped"""
167
+ return f"__{arg_name}_{index}"
168
+
169
+ def expand_node(
170
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
171
+ ) -> Collection[node.Node]:
172
+ nodes = []
173
+ for (
174
+ output_node,
175
+ parametrization_with_optional_docstring,
176
+ ) in self.parameterization.items():
177
+ if output_node == parameterize.PLACEHOLDER_PARAM_NAME:
178
+ output_node = node_.name
179
+ if isinstance(
180
+ parametrization_with_optional_docstring, tuple
181
+ ): # In this case it contains the docstring
182
+ (parameterization,) = parametrization_with_optional_docstring
183
+ else:
184
+ parameterization = parametrization_with_optional_docstring
185
+ docstring = self.format_doc_string(fn, output_node)
186
+ parameterization_splits = self.split_parameterizations(parameterization)
187
+ upstream_dependencies = parameterization_splits[ParametrizedDependencySource.UPSTREAM]
188
+ literal_dependencies = parameterization_splits[ParametrizedDependencySource.LITERAL]
189
+ grouped_list_dependencies = parameterization_splits[
190
+ ParametrizedDependencySource.GROUPED_LIST
191
+ ]
192
+ grouped_dict_dependencies = parameterization_splits[
193
+ ParametrizedDependencySource.GROUPED_DICT
194
+ ]
195
+
196
+ def replacement_function(
197
+ *args,
198
+ upstream_dependencies=upstream_dependencies,
199
+ literal_dependencies=literal_dependencies,
200
+ grouped_list_dependencies=grouped_list_dependencies,
201
+ grouped_dict_dependencies=grouped_dict_dependencies,
202
+ former_inputs=list(node_.input_types.keys()), # noqa
203
+ **kwargs,
204
+ ):
205
+ """This function rewrites what is passed in kwargs to the right kwarg for the function.
206
+ The passed in kwargs are all the dependencies of this node. Note that we actually have the "former inputs",
207
+ which are what the node declares as its dependencies. So, we just have to loop through all of them to
208
+ get the "new" value. This "new" value comes from the parameterization.
209
+
210
+ Note that much of this code should *probably* live within the source/value/grouped functions, but
211
+ it is here as we're not 100% sure about the abstraction.
212
+
213
+ TODO -- think about how the grouped/source/literal functions should be able to grab the values from kwargs/args.
214
+ Should be easy -- they should just have something like a "resolve(**kwargs)" function that they can call.
215
+ """
216
+ new_kwargs = {}
217
+ for node_input in former_inputs:
218
+ if node_input in upstream_dependencies:
219
+ # If the node is specified by `source`, then we get the value from the kwargs
220
+ new_kwargs[node_input] = kwargs[upstream_dependencies[node_input].source]
221
+ elif node_input in literal_dependencies:
222
+ # If the node is specified by `value`, then we get the literal value (no need for kwargs)
223
+ new_kwargs[node_input] = literal_dependencies[node_input].value
224
+ elif node_input in grouped_list_dependencies:
225
+ # If the node is specified by `group`, then we get the list of values from the kwargs or the literal
226
+ new_kwargs[node_input] = []
227
+ for replacement in grouped_list_dependencies[node_input].sources:
228
+ resolved_value = (
229
+ kwargs[replacement.source]
230
+ if replacement.get_dependency_type()
231
+ == ParametrizedDependencySource.UPSTREAM
232
+ else replacement.value
233
+ )
234
+ new_kwargs[node_input].append(resolved_value)
235
+ elif node_input in grouped_dict_dependencies:
236
+ # If the node is specified by `group`, then we get the dict of values from the kwargs or the literal
237
+ new_kwargs[node_input] = {}
238
+ for dependency, replacement in grouped_dict_dependencies[
239
+ node_input
240
+ ].sources.items():
241
+ resolved_value = (
242
+ kwargs[replacement.source]
243
+ if replacement.get_dependency_type()
244
+ == ParametrizedDependencySource.UPSTREAM
245
+ else replacement.value
246
+ )
247
+ new_kwargs[node_input][dependency] = resolved_value
248
+ elif node_input in kwargs:
249
+ new_kwargs[node_input] = kwargs[node_input]
250
+ # This case is left blank for optional parameters. If we error here, we'll break
251
+ # the (supported) case of optionals. We do know whether its optional but for
252
+ # now the error will be clear enough
253
+ return node_.callable(*args, **new_kwargs)
254
+
255
+ new_input_types = {}
256
+ grouped_dependencies = {
257
+ **grouped_list_dependencies,
258
+ **grouped_dict_dependencies,
259
+ }
260
+ for param, val in node_.input_types.items():
261
+ if param in upstream_dependencies:
262
+ new_input_types[upstream_dependencies[param].source] = (
263
+ val # We replace with the upstream_dependencies
264
+ )
265
+ elif param in grouped_dependencies:
266
+ # These are the components of the individual sequence
267
+ # E.G. if the parameter is List[int], the individual type is just int
268
+ grouped_dependency_spec = grouped_dependencies[param]
269
+ sequence_component_type = grouped_dependency_spec.resolve_dependency_type(
270
+ val[0], param
271
+ )
272
+ unpacked_dependencies = (
273
+ grouped_dependency_spec.sources
274
+ if grouped_dependency_spec.get_dependency_type()
275
+ == ParametrizedDependencySource.GROUPED_LIST
276
+ else grouped_dependency_spec.sources.values()
277
+ )
278
+ for dep in unpacked_dependencies:
279
+ if dep.get_dependency_type() == ParametrizedDependencySource.UPSTREAM:
280
+ # TODO -- think through what happens if we have optional pieces...
281
+ # I think that we shouldn't allow it...
282
+ new_input_types[dep.source] = (
283
+ sequence_component_type,
284
+ val[1],
285
+ )
286
+ elif param not in literal_dependencies:
287
+ new_input_types[param] = (
288
+ val # We just use the standard one, nothing is getting replaced
289
+ )
290
+ nodes.append(
291
+ node_.copy_with(
292
+ name=output_node,
293
+ doc_string=docstring, # TODO -- change docstring
294
+ callabl=functools.partial(
295
+ replacement_function,
296
+ **{parameter: val.value for parameter, val in literal_dependencies.items()},
297
+ ),
298
+ input_types=new_input_types,
299
+ include_refs=False, # Include refs is here as this is earlier than compile time
300
+ # TODO -- figure out why this isn't getting replaced later...
301
+ )
302
+ )
303
+ return nodes
304
+
305
+ def validate(self, fn: Callable):
306
+ signature = inspect.signature(fn)
307
+ func_param_names = set(signature.parameters.keys())
308
+ try:
309
+ for output_name, _mappings in self.parameterization.items():
310
+ # TODO -- separate out into the two dependency-types
311
+ if output_name == self.PLACEHOLDER_PARAM_NAME:
312
+ output_name = fn.__name__
313
+ self.format_doc_string(fn, output_name)
314
+ except KeyError as e:
315
+ raise base.InvalidDecoratorException(
316
+ f"Function docstring templating is incorrect. "
317
+ f"Please fix up the docstring {fn.__module__}.{fn.__name__}."
318
+ ) from e
319
+
320
+ if self.RESERVED_KWARG in func_param_names:
321
+ raise base.InvalidDecoratorException(
322
+ f"Error function {fn.__module__}.{fn.__name__} cannot have '{self.RESERVED_KWARG}'"
323
+ f"as a parameter it is reserved."
324
+ )
325
+ missing_parameters = set()
326
+ for mapping in self.parameterization.values():
327
+ for param_to_replace in mapping:
328
+ if param_to_replace not in func_param_names:
329
+ missing_parameters.add(param_to_replace)
330
+ if missing_parameters:
331
+ raise base.InvalidDecoratorException(
332
+ f"Parametrization is invalid: the following parameters don't appear in the function itself: {', '.join(missing_parameters)}"
333
+ )
334
+ type_hints = typing.get_type_hints(fn)
335
+ for _output_name, mapping in self.parameterization.items():
336
+ # TODO -- look a the origin type and determine that its a sequence
337
+ # We can just use the GroupedListDependency to do this
338
+ invalid_types = []
339
+ if isinstance(mapping, tuple):
340
+ mapping = mapping[0]
341
+ for param, replacement_value in mapping.items():
342
+ param_annotation = type_hints[param]
343
+ if typing_inspect.is_optional_type(param_annotation):
344
+ param_annotation = typing_inspect.get_args(param_annotation)[0]
345
+ is_generic = typing_inspect.is_generic_type(param_annotation)
346
+ if (
347
+ replacement_value.get_dependency_type()
348
+ == ParametrizedDependencySource.GROUPED_LIST
349
+ ):
350
+ if not is_generic:
351
+ invalid_types.append((param, param_annotation))
352
+ else:
353
+ origin = typing_inspect.get_origin(param_annotation)
354
+ if origin != list:
355
+ invalid_types.append((param, param_annotation))
356
+ # 3.9 + this works
357
+ # 3.8 they changed it, so it gives false positives, but we're OK not fixing
358
+ # for older versions of python
359
+ args = typing_inspect.get_args(param_annotation)
360
+ if not len(args) == 1:
361
+ invalid_types.append((param, param_annotation))
362
+ elif (
363
+ replacement_value.get_dependency_type()
364
+ == ParametrizedDependencySource.GROUPED_DICT
365
+ ):
366
+ if not is_generic:
367
+ invalid_types.append((param, param_annotation))
368
+ else:
369
+ origin = typing_inspect.get_origin(param_annotation)
370
+ if origin != dict:
371
+ invalid_types.append((param, param_annotation))
372
+ args = typing_inspect.get_args(param_annotation)
373
+ if not len(args) == 2:
374
+ invalid_types.append((param, param_annotation))
375
+ elif args[0] != str:
376
+ invalid_types.append((param, param_annotation))
377
+ if invalid_types:
378
+ raise base.InvalidDecoratorException(
379
+ f"Validation for fn: {fn.__qualname__} All parameters with a group() parameterization must be annotated as a list: "
380
+ f"the following are not: {', '.join([f'{param} ({annotation})' for param, annotation in invalid_types])}"
381
+ )
382
+
383
+ def format_doc_string(self, fn: Callable, output_name: str) -> str:
384
+ """Helper function to format a function documentation string.
385
+
386
+ :param doc: the string template to format
387
+ :param output_name: the output name of the function
388
+ :param params: the parameter mappings
389
+ :return: formatted string
390
+ :raises: KeyError if there is a template variable missing from the parameter mapping.
391
+ """
392
+
393
+ class IdentityDict(dict):
394
+ # quick hack to allow for formatting of missing parameters
395
+ def __missing__(self, key):
396
+ return key
397
+
398
+ if output_name in self.specified_docstrings:
399
+ return self.specified_docstrings[output_name]
400
+ doc = fn.__doc__
401
+ if doc is None:
402
+ return None
403
+ parameterizations = self.parameterization.copy()
404
+ if self.PLACEHOLDER_PARAM_NAME in parameterizations:
405
+ parameterizations[fn.__name__] = parameterizations.pop(self.PLACEHOLDER_PARAM_NAME)
406
+ parametrization = parameterizations[output_name]
407
+ upstream_dependencies = {
408
+ parameter: replacement.source
409
+ for parameter, replacement in parametrization.items()
410
+ if replacement.get_dependency_type() == ParametrizedDependencySource.UPSTREAM
411
+ }
412
+ literal_dependencies = {
413
+ parameter: replacement.value
414
+ for parameter, replacement in parametrization.items()
415
+ if replacement.get_dependency_type() == ParametrizedDependencySource.LITERAL
416
+ }
417
+ return doc.format_map(
418
+ IdentityDict(
419
+ **{self.RESERVED_KWARG: output_name},
420
+ **{**upstream_dependencies, **literal_dependencies},
421
+ )
422
+ )
423
+
424
+
425
+ class parameterize_values(parameterize):
426
+ """Expands a single function into n, each of which corresponds to a function in which the parameter value is \
427
+ replaced by that `specific value`.
428
+
429
+ .. code-block:: python
430
+
431
+ import pandas as pd
432
+ from hamilton.function_modifiers import parameterize_values
433
+ import internal_package_with_logic
434
+
435
+ ONE_OFF_DATES = {
436
+ #output name # doc string # input value to function
437
+ ('D_ELECTION_2016', 'US Election 2016 Dummy'): '2016-11-12',
438
+ ('SOME_OUTPUT_NAME', 'Doc string for this thing'): 'value to pass to function',
439
+ }
440
+ # parameter matches the name of the argument in the function below
441
+ @parameterize_values(parameter='one_off_date', assigned_output=ONE_OFF_DATES)
442
+ def create_one_off_dates(date_index: pd.Series, one_off_date: str) -> pd.Series:
443
+ '''Given a date index, produces a series where a 1 is placed at the date index that would contain that event.'''
444
+ one_off_dates = internal_package_with_logic.get_business_week(one_off_date)
445
+ return internal_package_with_logic.bool_to_int(date_index.isin([one_off_dates]))
446
+
447
+ """
448
+
449
+ def __init__(self, parameter: str, assigned_output: dict[tuple[str, str], Any]):
450
+ """Constructor for a modifier that expands a single function into n, each of which
451
+ corresponds to a function in which the parameter value is replaced by that *specific value*.
452
+
453
+ :param parameter: Parameter to expand on.
454
+ :param assigned_output: A map of tuple of [parameter names, documentation] to values
455
+ """
456
+ for node_ in assigned_output.keys():
457
+ if not isinstance(node_, tuple):
458
+ raise base.InvalidDecoratorException(
459
+ f"assigned_output key is incorrect: {node_}. The parameterized decorator needs a dict of "
460
+ "[name, doc string] -> value to function."
461
+ )
462
+ super(parameterize_values, self).__init__(
463
+ **{
464
+ output: ({parameter: value(literal_value)}, documentation)
465
+ for (output, documentation), literal_value in assigned_output.items()
466
+ }
467
+ )
468
+
469
+
470
+ @deprecation.deprecated(
471
+ warn_starting=(1, 10, 0),
472
+ fail_starting=(2, 0, 0),
473
+ use_this=parameterize_values,
474
+ explanation="We now support three parametrize decorators. @parameterize, @parameterize_values, and @parameterize_inputs",
475
+ migration_guide="https://github.com/apache/hamilton/blob/main/decorators.md#migrating-parameterized",
476
+ )
477
+ class parametrized(parameterize_values):
478
+ pass
479
+
480
+
481
+ class parameterize_sources(parameterize):
482
+ """Expands a single function into `n`, each of which corresponds to a function in which the parameters specified \
483
+ are mapped to the specified inputs. Note this decorator and ``@parameterize_values`` are quite similar, except \
484
+ that the input here is another DAG node(s), i.e. column/input, rather than a specific scalar/static value.
485
+
486
+ .. code-block:: python
487
+
488
+ import pandas as pd
489
+ from hamilton.function_modifiers import parameterize_sources
490
+
491
+ @parameterize_sources(
492
+ D_ELECTION_2016_shifted=dict(one_off_date='D_ELECTION_2016'),
493
+ SOME_OUTPUT_NAME=dict(one_off_date='SOME_INPUT_NAME')
494
+ )
495
+ def date_shifter(one_off_date: pd.Series) -> pd.Series:
496
+ '''{one_off_date} shifted by 1 to create {output_name}'''
497
+ return one_off_date.shift(1)
498
+
499
+ """
500
+
501
+ def __init__(self, **parameterization: dict[str, str]):
502
+ """Constructor for a modifier that expands a single function into n, each of which corresponds to replacing\
503
+ some subset of the specified parameters with specific upstream nodes.
504
+
505
+ Note this decorator and `@parametrized_input` are similar, except this one allows multiple \
506
+ parameters to be mapped to multiple function arguments (and it fixes the spelling mistake).
507
+
508
+ `parameterized_sources` allows you keep your code DRY by reusing the same function but replace the inputs \
509
+ to create multiple corresponding distinct outputs. We see here that `parameterized_inputs` allows you to keep \
510
+ your code DRY by reusing the same function to create multiple distinct outputs. The key word arguments passed \
511
+ have to have the following structure:
512
+ > OUTPUT_NAME = Mapping of function argument to input that should go into it.
513
+
514
+ The documentation for the output is taken from the function. The documentation string can be templatized with\
515
+ the parameter names of the function and the reserved value `output_name` - those will be replaced with the\
516
+ corresponding values from the parameterization.
517
+
518
+ :param \\*\\*parameterization: kwargs of output name to dict of parameter mappings.
519
+ """
520
+ self.parametrization = parameterization
521
+ if not parameterization:
522
+ raise ValueError("Cannot pass empty/None dictionary to parameterize_sources")
523
+ for output, mappings in parameterization.items():
524
+ if not mappings:
525
+ raise ValueError(
526
+ f"Error, {output} has a none/empty dictionary mapping. Please fill it."
527
+ )
528
+ super(parameterize_sources, self).__init__(
529
+ **{
530
+ output: {
531
+ parameter: source(upstream_node) for parameter, upstream_node in mapping.items()
532
+ }
533
+ for output, mapping in parameterization.items()
534
+ }
535
+ )
536
+
537
+
538
+ @deprecation.deprecated(
539
+ warn_starting=(1, 10, 0),
540
+ fail_starting=(2, 0, 0),
541
+ use_this=parameterize_sources,
542
+ explanation="We now support three parametrize decorators. @parameterize, "
543
+ "@parameterize_values, and @parameterize_inputs",
544
+ migration_guide="https://github.com/apache/hamilton/blob/main/decorators.md#migrating"
545
+ "-parameterized",
546
+ )
547
+ class parametrized_input(parameterize):
548
+ def __init__(self, parameter: str, variable_inputs: dict[str, tuple[str, str]]):
549
+ """Constructor for a modifier that expands a single function into n, each of which
550
+ corresponds to the specified parameter replaced by a *specific input column*.
551
+
552
+ Note this decorator and `@parametrized` are quite similar, except that the input here is another DAG node,
553
+ i.e. column, rather than some specific value.
554
+
555
+ The `parameterized_input` allows you keep your code DRY by reusing the same function but replace the inputs
556
+ to create multiple corresponding distinct outputs. The _parameter_ key word argument has to match one of the
557
+ arguments in the function. The rest of the arguments are pulled from items inside the DAG.
558
+ The _assigned_inputs_ key word argument takes in a dictionary of \
559
+ input_column -> tuple(Output Name, Documentation string).
560
+
561
+ :param parameter: Parameter to expand on.
562
+ :param variable_inputs: A map of tuple of [parameter names, documentation] to values
563
+ """
564
+ for val in variable_inputs.values():
565
+ if not isinstance(val, tuple):
566
+ raise base.InvalidDecoratorException(
567
+ f"assigned_output key is incorrect: {node}. The parameterized decorator needs a dict of "
568
+ "input column -> [name, description] to function."
569
+ )
570
+ super(parametrized_input, self).__init__(
571
+ **{
572
+ output: ({parameter: source(value)}, documentation)
573
+ for value, (output, documentation) in variable_inputs.items()
574
+ }
575
+ )
576
+
577
+
578
+ @deprecation.deprecated(
579
+ warn_starting=(1, 10, 0),
580
+ fail_starting=(2, 0, 0),
581
+ use_this=parameterize_sources,
582
+ explanation="We now support three parametrize decorators. @parameterize, @parameterize_values, and @parameterize_inputs",
583
+ migration_guide="https://github.com/apache/hamilton/blob/main/decorators.md#migrating-parameterized",
584
+ )
585
+ class parameterized_inputs(parameterize_sources):
586
+ pass
587
+
588
+
589
+ class extract_columns(base.SingleNodeNodeTransformer):
590
+ def __init__(self, *columns: tuple[str, str] | str, fill_with: Any = None):
591
+ """Constructor for a modifier that expands a single function into the following nodes:
592
+
593
+ - n functions, each of which take in the original dataframe and output a specific column
594
+ - 1 function that outputs the original dataframe
595
+
596
+ :param columns: Columns to extract, that can be a list of tuples of (name, documentation) or just names.
597
+ :param fill_with: If you want to extract a column that doesn't exist, do you want to fill it with a default \
598
+ value? Or do you want to error out? Leave empty/None to error out, set fill_value to dynamically create a \
599
+ column.
600
+ """
601
+ super(extract_columns, self).__init__()
602
+ if not columns:
603
+ raise base.InvalidDecoratorException(
604
+ "Error empty arguments passed to extract_columns decorator."
605
+ )
606
+ elif isinstance(columns[0], list):
607
+ raise base.InvalidDecoratorException(
608
+ "Error list passed in. Please `*` in front of it to expand it."
609
+ )
610
+ self.columns = columns
611
+ self.fill_with = fill_with
612
+
613
+ @staticmethod
614
+ def validate_return_type(fn: Callable):
615
+ """Validates that the return type of the function is a pandas dataframe.
616
+ :param fn: Function to validate
617
+ """
618
+ output_type = typing.get_type_hints(fn).get("return")
619
+ try:
620
+ registry.get_column_type_from_df_type(output_type)
621
+ except NotImplementedError as e:
622
+ raise base.InvalidDecoratorException(
623
+ # TODO: capture was dataframe libraries are supported and print here.
624
+ f"Error {fn} does not output a type we know about. Is it a dataframe type we "
625
+ f"support? "
626
+ ) from e
627
+
628
+ def validate(self, fn: Callable):
629
+ """A function is invalid if it does not output a dataframe.
630
+
631
+ :param fn: Function to validate.
632
+ :raises: InvalidDecoratorException If the function does not output a Dataframe
633
+ """
634
+ extract_columns.validate_return_type(fn)
635
+
636
+ def transform_node(
637
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
638
+ ) -> Collection[node.Node]:
639
+ """For each column to extract, output a node that extracts that column. Also, output the original dataframe
640
+ generator.
641
+ :param node_: Node to transform
642
+ :param config: Config to use
643
+ :param fn: Function to extract columns from. Must output a dataframe.
644
+ :return: A collection of nodes --
645
+ one for the original dataframe generator, and another for each column to extract.
646
+ """
647
+ fn = node_.callable
648
+ base_doc = node_.documentation
649
+
650
+ # if fn is an async function
651
+ if inspect.iscoroutinefunction(fn):
652
+
653
+ async def df_generator(*args, **kwargs) -> Any:
654
+ df_generated = await fn(*args, **kwargs)
655
+ if self.fill_with is not None:
656
+ for col in self.columns:
657
+ if col not in df_generated:
658
+ registry.fill_with_scalar(df_generated, col, self.fill_with)
659
+ assert col in df_generated
660
+ return df_generated
661
+
662
+ else:
663
+
664
+ def df_generator(*args, **kwargs) -> Any:
665
+ df_generated = fn(*args, **kwargs)
666
+ if self.fill_with is not None:
667
+ for col in self.columns:
668
+ if col not in df_generated:
669
+ registry.fill_with_scalar(df_generated, col, self.fill_with)
670
+ assert col in df_generated
671
+ return df_generated
672
+
673
+ output_nodes = [node_.copy_with(callabl=df_generator)]
674
+ output_type = node_.type
675
+ series_type = registry.get_column_type_from_df_type(output_type)
676
+ for column in self.columns:
677
+ doc_string = base_doc # default doc string of base function.
678
+ if isinstance(column, tuple): # Expand tuple into constituents
679
+ column, doc_string = column
680
+
681
+ if inspect.iscoroutinefunction(fn):
682
+
683
+ async def extractor_fn(column_to_extract: str = column, **kwargs) -> Any:
684
+ df = kwargs[node_.name]
685
+ if column_to_extract not in df:
686
+ raise base.InvalidDecoratorException(
687
+ f"No such column: {column_to_extract} produced by {node_.name}. "
688
+ f"It only produced {str(df.columns)}"
689
+ )
690
+ return registry.get_column(df, column_to_extract)
691
+
692
+ else:
693
+
694
+ def extractor_fn(
695
+ column_to_extract: str = column, **kwargs
696
+ ) -> Any: # avoiding problems with closures
697
+ df = kwargs[node_.name]
698
+ if column_to_extract not in df:
699
+ raise base.InvalidDecoratorException(
700
+ f"No such column: {column_to_extract} produced by {node_.name}. "
701
+ f"It only produced {str(df.columns)}"
702
+ )
703
+ return registry.get_column(df, column_to_extract)
704
+
705
+ output_nodes.append(
706
+ node.Node(
707
+ column,
708
+ series_type,
709
+ doc_string,
710
+ extractor_fn,
711
+ input_types={node_.name: output_type},
712
+ tags=node_.tags.copy(),
713
+ )
714
+ )
715
+ return output_nodes
716
+
717
+
718
+ def _determine_fields_to_extract(
719
+ fields: dict[str, Any] | list[str] | None, output_type: Any
720
+ ) -> dict[str, Any]:
721
+ """Determines which fields to extract based on user requested fields and the output type of
722
+ the return type of the function.
723
+
724
+ :param fields: Dict of fields to extract.
725
+ :param output_type: The output type of the node function.
726
+ :return: List of field types.
727
+ """
728
+
729
+ output_type_error = (
730
+ f"For extracting fields, the decorated function output type must be a `dict` or a "
731
+ f"`typing.Dict` with or without type parameters (i.e. `dict[str, int]` or "
732
+ f"`typing.Dict[str, int]`), not: {output_type}"
733
+ )
734
+
735
+ if output_type == dict or output_type == dict:
736
+ # NOTE: typing_inspect.is_generic_type(typing.Dict) without type parameters returns True,
737
+ # so we need to address the bare dictionaries first before generics.
738
+ if fields is None or not isinstance(fields, dict):
739
+ raise base.InvalidDecoratorException(
740
+ "When extracting fields from a function that returns a bare `dict` output without "
741
+ "type parameters, you must supply a `dict` mapping field names to types."
742
+ )
743
+ elif typing_inspect.is_generic_type(output_type):
744
+ base_type = typing_inspect.get_origin(output_type)
745
+ if base_type != dict and base_type != dict:
746
+ raise base.InvalidDecoratorException(output_type_error)
747
+ if fields is None:
748
+ raise base.InvalidDecoratorException(
749
+ "When extracting fields from a function that returns a generic `dict`, you must "
750
+ "supply either a `dict` (`typing.Dict`) mapping field names to types or "
751
+ "alternatively a `list` (`typing.List`) of field names."
752
+ )
753
+ output_args = typing_inspect.get_args(output_type)
754
+ if len(output_args) != 2:
755
+ raise base.InvalidDecoratorException(
756
+ f"When extracting fields from a function that returns a generic `dict`, you "
757
+ f"must specify only two type parameters (key, value), not {output_args}."
758
+ )
759
+ if isinstance(fields, list):
760
+ fields = {field: output_args[1] for field in fields} # Infer type from annotation
761
+ elif typing_extensions.is_typeddict(output_type):
762
+ typed_dict_fields = typing.get_type_hints(output_type) # Dict of field name -> type
763
+ errors = []
764
+ if fields is None:
765
+ fields = typed_dict_fields # Infer fields and types from annotation
766
+ elif isinstance(fields, list):
767
+ reduced_fields = {}
768
+ for field in fields:
769
+ if field not in typed_dict_fields:
770
+ errors.append(f"{field} is not a field in the `TypedDict` {output_type}.")
771
+ reduced_fields[field] = typed_dict_fields[field]
772
+ fields = reduced_fields
773
+ elif isinstance(fields, dict):
774
+ for field_name, field_type in fields.items():
775
+ expected_type = typed_dict_fields.get(field_name, None)
776
+ if expected_type is None:
777
+ errors.append(f"{field_name} is not a field in the `TypedDict` {output_type}.")
778
+ continue
779
+ elif expected_type == field_type or htypes.custom_subclass_check(
780
+ field_type, expected_type
781
+ ):
782
+ continue
783
+ errors.append(
784
+ f"Error {field_name} did not match the TypedDict annotation's field "
785
+ f"{field_type}. Expected {expected_type}."
786
+ )
787
+ if errors:
788
+ raise base.InvalidDecoratorException(
789
+ f"Error {fields} did not match a subset of the TypedDict annotation's fields "
790
+ f"{typed_dict_fields}. The following fields were not valid: {errors}."
791
+ )
792
+ else:
793
+ raise base.InvalidDecoratorException(output_type_error)
794
+
795
+ assert isinstance(fields, dict), "Internal error: fields should be a dict at this point."
796
+ _validate_extract_fields(fields)
797
+
798
+ return fields
799
+
800
+
801
+ def _validate_extract_fields(fields: dict):
802
+ """Validates the fields dict for extract field.
803
+ Rules are:
804
+ - All keys must be strings
805
+ - All values must be types
806
+ - It must not be empty
807
+
808
+ :param fields: Constructor argument to extract_fields
809
+ :raises InvalidDecoratorException: If the fields dict is invalid.
810
+ """
811
+ if not fields:
812
+ raise base.InvalidDecoratorException(
813
+ "Error an empty dict, or no dict, passed to extract_fields decorator."
814
+ )
815
+ elif not isinstance(fields, dict):
816
+ raise base.InvalidDecoratorException(f"Error, please pass in a dict, not {type(fields)}")
817
+ else:
818
+ errors = []
819
+ for field, field_type in fields.items():
820
+ if not isinstance(field, str):
821
+ errors.append(f"{field} is not a string. All keys must be strings.")
822
+
823
+ # second condition needed because isinstance(Any, type) == False for Python <3.11
824
+ if not (
825
+ isinstance(field_type, type)
826
+ or field_type is Any
827
+ or typing_inspect.is_generic_type(field_type)
828
+ or typing_inspect.is_union_type(field_type)
829
+ ):
830
+ errors.append(f"{field} does not declare a type. Instead it passes {field_type}.")
831
+
832
+ if errors:
833
+ raise base.InvalidDecoratorException(
834
+ f"Error, found these {errors}. Please pass in a dict of string to types. "
835
+ )
836
+
837
+
838
+ class extract_fields(base.SingleNodeNodeTransformer):
839
+ """Extracts fields from a dictionary of output."""
840
+
841
+ output_type: Any
842
+ resolved_fields: dict[str, type]
843
+
844
+ def __init__(
845
+ self,
846
+ fields: dict[str, Any] | list[str] | Any | None = None,
847
+ *others,
848
+ fill_with: Any = None,
849
+ ):
850
+ """Constructor for a modifier that expands a single function into the following nodes:
851
+
852
+ - n functions, each of which take in the original dict and output a specific field
853
+ - 1 function that outputs the original dict
854
+
855
+ :param fields: Fields to extract. Can be a dict of field names to types, a list of field names, or a single field name.
856
+ :param others: Additional fields names to extract - argument unpacking. Ignored if `fields` is a dict.
857
+ :param fill_with: If you want to extract a field that doesn't exist, do you want to fill it with a default \
858
+ value? Or do you want to error out? Leave empty/None to error out, set fill_value to dynamically create a \
859
+ field value.
860
+ """
861
+ super(extract_fields, self).__init__()
862
+ if isinstance(fields, list):
863
+ fields = fields + list(others)
864
+ elif fields and not isinstance(fields, dict):
865
+ fields = [fields] + list(others)
866
+ self.fields = fields
867
+ self.fill_with = fill_with
868
+
869
+ def validate(self, fn: Callable):
870
+ """A function is invalid if it is not annotated with a dict or typing.Dict return type or if the
871
+ fields to extract are not valid.
872
+
873
+ :param fn: Function to validate.
874
+ :raises: InvalidDecoratorException If the function is not annotated with a dict or typing.Dict type as output.
875
+ """
876
+ self.output_type = typing.get_type_hints(fn).get("return")
877
+ self.resolved_fields = _determine_fields_to_extract(self.fields, self.output_type)
878
+
879
+ def transform_node(
880
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
881
+ ) -> Collection[node.Node]:
882
+ """For each field to extract, output a node that extracts that field. Also, output the original TypedDict
883
+ generator.
884
+
885
+ :param node_:
886
+ :param config:
887
+ :param fn: Function to extract columns from. Must output a dataframe.
888
+ :return: A collection of nodes --
889
+ one for the original dataframe generator, and another for each column to extract.
890
+ """
891
+ fn = node_.callable
892
+ base_doc = node_.documentation
893
+
894
+ # if fn is async
895
+ if inspect.iscoroutinefunction(fn):
896
+
897
+ async def dict_generator(*args, **kwargs): # type: ignore
898
+ dict_generated = await fn(*args, **kwargs)
899
+ if self.fill_with is not None:
900
+ for field in self.resolved_fields:
901
+ if field not in dict_generated:
902
+ dict_generated[field] = self.fill_with
903
+ return dict_generated
904
+
905
+ else:
906
+
907
+ def dict_generator(*args, **kwargs): # type: ignore
908
+ dict_generated = fn(*args, **kwargs)
909
+ if self.fill_with is not None:
910
+ for field in self.resolved_fields:
911
+ if field not in dict_generated:
912
+ dict_generated[field] = self.fill_with
913
+ return dict_generated
914
+
915
+ output_nodes = [node_.copy_with(callabl=dict_generator)]
916
+
917
+ for field, field_type in self.resolved_fields.items():
918
+ doc_string = base_doc # default doc string of base function.
919
+
920
+ # This extractor is constructed to avoid closure issues.
921
+ def extractor_fn(field_to_extract: str = field, **kwargs) -> field_type: # type: ignore
922
+ dt = kwargs[node_.name]
923
+ if field_to_extract not in dt:
924
+ raise base.InvalidDecoratorException(
925
+ f"No such field: {field_to_extract} produced by {node_.name}. "
926
+ f"It only produced {list(dt.keys())}"
927
+ )
928
+ return kwargs[node_.name][field_to_extract]
929
+
930
+ output_nodes.append(
931
+ node.Node(
932
+ field,
933
+ field_type,
934
+ doc_string,
935
+ extractor_fn,
936
+ input_types={node_.name: self.output_type},
937
+ tags=node_.tags.copy(),
938
+ )
939
+ )
940
+ return output_nodes
941
+
942
+
943
+ def _determine_fields_to_unpack(fields: list[str], output_type: Any) -> list[type]:
944
+ """Determines which fields to unpack based on user requested fields and the output type of
945
+ the return type of the function.
946
+
947
+ :param fields: List of fields to to unpack.
948
+ :param output_type: The output type of the node function.
949
+ :return: List of field types.
950
+ """
951
+
952
+ base_type = typing_inspect.get_origin(output_type) # Returns None when output_type is None
953
+ if base_type != tuple and base_type != tuple:
954
+ message = (
955
+ f"For unpacking fields, the decorated function output type must be either an "
956
+ f"explicit length tuple (e.g.`tuple[int, str]`, `typing.Tuple[int, str]`) or an "
957
+ f"indeterminate length tuple (e.g. `tuple[int, ...]`, `typing.Tuple[int, ...]`), "
958
+ f"not: {output_type}"
959
+ )
960
+ raise base.InvalidDecoratorException(message)
961
+
962
+ output_args = typing_inspect.get_args(output_type)
963
+ num_ellipsis = output_args.count(Ellipsis)
964
+ if num_ellipsis > 1:
965
+ raise base.InvalidDecoratorException(
966
+ f"Invalid tuple: Found more than one ellipsis ('...'): {output_type}"
967
+ )
968
+ elif num_ellipsis == 1:
969
+ if len(output_args) != 2 or output_args[1] is not Ellipsis:
970
+ raise base.InvalidDecoratorException(
971
+ f"Invalid tuple: Ellipsis ('...') must be second element: {output_type}"
972
+ )
973
+ # Valid Indeterminate length tuple, e.g. `tuple[int, ...]`, `typing.Tuple[int, ...]`
974
+ output_args = tuple(output_args[0] for _ in range(len(fields)))
975
+
976
+ if len(output_args) < len(fields):
977
+ raise base.InvalidDecoratorException(
978
+ f"Number of unpacked fields ({len(fields)}) is greater than the number of fields in "
979
+ f"the output type ({len(output_args)}): {output_type}"
980
+ )
981
+
982
+ errors = []
983
+ field_types = []
984
+ for idx, arg in enumerate(output_args):
985
+ # Determine if the type is a valid type. Note that for Python <3.11, `Any` is not a type
986
+ if not (
987
+ isinstance(arg, type)
988
+ or arg is Any
989
+ or typing_inspect.is_generic_type(arg)
990
+ or typing_inspect.is_union_type(arg)
991
+ ):
992
+ field_name = fields[idx]
993
+ errors.append(f"Field {field_name} (index {idx}) does not declare a valid type: {arg}")
994
+ field_types.append(arg)
995
+
996
+ if errors:
997
+ raise base.InvalidDecoratorException(f"Found errors in the output type: {errors}")
998
+
999
+ return field_types
1000
+
1001
+
1002
+ class unpack_fields(base.SingleNodeNodeTransformer):
1003
+ """Unpacks fields from a tuple output.
1004
+
1005
+ Expands a single function into the following nodes:
1006
+
1007
+ - 1 function that outputs the original tuple
1008
+ - n functions, each of which take in the original tuple and output a specific field
1009
+
1010
+ The decorated function must have an return type of either `tuple` (python 3.9+) or
1011
+ `typing.Tuple`, and must specify either:
1012
+ - An explicit length tuple (e.g.`tuple[int, str]`, `typing.Tuple[int, str]`)
1013
+ - An indeterminate length tuple (e.g. `tuple[int, ...]`, `typing.Tuple[int, ...]`)
1014
+
1015
+ :param fields: Fields to unpack from the return value of the decorated function.
1016
+ """
1017
+
1018
+ output_type: Any
1019
+ field_types: list[type]
1020
+
1021
+ def __init__(self, *fields: str):
1022
+ super().__init__()
1023
+ self.fields = list(fields)
1024
+
1025
+ @override
1026
+ def validate(self, fn: Callable):
1027
+ """Validates that the return type of the function is a tuple or typing.Tuple with the
1028
+
1029
+ :param fn: Function to validate
1030
+ :raises: InvalidDecoratorException If the function does not output a tuple or typing.Tuple type.
1031
+ """
1032
+ output_type = typing.get_type_hints(fn).get("return")
1033
+ field_types = _determine_fields_to_unpack(self.fields, output_type)
1034
+ self.field_types = field_types
1035
+ self.output_type = output_type
1036
+
1037
+ @override
1038
+ def transform_node(
1039
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
1040
+ ) -> Collection[node.Node]:
1041
+ """Unpacks the specified fields form the tuple output into separate nodes.
1042
+
1043
+ :param node_: Node to transform
1044
+ :param config: Config to use
1045
+ :param fn: Function to unpack fields from. Must output a tuple.
1046
+ :return: A collection of nodes --
1047
+ one for the original tuple generator, and another for each field to unpack.
1048
+ """
1049
+ fn = node_.callable
1050
+ base_doc = node_.documentation
1051
+ base_tags = node_.tags.copy()
1052
+
1053
+ if inspect.iscoroutinefunction(fn):
1054
+
1055
+ async def tuple_generator(*args, **kwargs): # type: ignore
1056
+ tuple_generated = await fn(*args, **kwargs)
1057
+ return tuple_generated
1058
+
1059
+ else:
1060
+
1061
+ def tuple_generator(*args, **kwargs):
1062
+ tuple_generated = fn(*args, **kwargs)
1063
+ return tuple_generated
1064
+
1065
+ output_nodes = [node_.copy_with(callabl=tuple_generator)]
1066
+
1067
+ for idx, (field_name, field_type) in enumerate(
1068
+ zip(self.fields, self.field_types, strict=False)
1069
+ ):
1070
+
1071
+ def extractor(field_index: int = idx, **kwargs) -> field_type: # type: ignore
1072
+ # This extractor is constructed to avoid closure issues.
1073
+ dt = kwargs[node_.name]
1074
+ if field_index < 0 or field_index >= len(dt):
1075
+ raise base.InvalidDecoratorException(
1076
+ f"Out of bounds field: {field_index} produced by {node_.name}. "
1077
+ f"It only produced {list(dt)} fields."
1078
+ )
1079
+ return kwargs[node_.name][field_index]
1080
+
1081
+ output_nodes.append(
1082
+ node.Node(
1083
+ field_name,
1084
+ field_type,
1085
+ base_doc,
1086
+ extractor,
1087
+ input_types={node_.name: self.output_type},
1088
+ tags=base_tags,
1089
+ )
1090
+ )
1091
+ return output_nodes
1092
+
1093
+
1094
+ @dataclasses.dataclass
1095
+ class ParameterizedExtract:
1096
+ """Dataclass to hold inputs for @parameterize and @parameterize_extract_columns.
1097
+
1098
+ :param outputs: A tuple of strings, each of which is the name of an output.
1099
+ :param input_mapping: A dictionary of string to ParametrizedDependency. The string is the name of the python \
1100
+ parameter of the decorated function, and the value is a "source"/"value" which will be passed as input for that\
1101
+ parameter to the function.
1102
+ """
1103
+
1104
+ outputs: tuple[str, ...]
1105
+ input_mapping: dict[str, ParametrizedDependency]
1106
+
1107
+
1108
+ class parameterize_extract_columns(base.NodeExpander):
1109
+ """`@parameterize_extract_columns` gives you the power of both `@extract_columns` and `@parameterize` in one\
1110
+ decorator.
1111
+
1112
+ It takes in a list of `Parameterized_Extract` objects, each of which is composed of:
1113
+ 1. A list of columns to extract, and
1114
+ 2. A parameterization that gets used
1115
+
1116
+ In the following case, we produce four columns, two for each parameterization:
1117
+
1118
+ .. code-block:: python
1119
+
1120
+ import pandas as pd
1121
+ from function_modifiers import parameterize_extract_columns, ParameterizedExtract, source, value
1122
+ @parameterize_extract_columns(
1123
+ ParameterizedExtract(
1124
+ ("outseries1a", "outseries2a"),
1125
+ {"input1": source("inseries1a"), "input2": source("inseries1b"), "input3": value(10)},
1126
+ ),
1127
+ ParameterizedExtract(
1128
+ ("outseries1b", "outseries2b"),
1129
+ {"input1": source("inseries2a"), "input2": source("inseries2b"), "input3": value(100)},
1130
+ ),
1131
+ )
1132
+ def fn(input1: pd.Series, input2: pd.Series, input3: float) -> pd.DataFrame:
1133
+ return pd.concat([input1 * input2 * input3, input1 + input2 + input3], axis=1)
1134
+
1135
+ """
1136
+
1137
+ def __init__(self, *extract_config: ParameterizedExtract, reassign_columns: bool = True):
1138
+ """Initializes a `parameterized_extract` decorator. Note this currently works for series,
1139
+ but the plan is to extend it to fields as well...
1140
+
1141
+ :param extract_config: A configuration consisting of a list ParameterizedExtract classes\
1142
+ These contain the information of a `@parameterized` and `@extract...` together.
1143
+ :param reassign_columns: Whether we want to reassign the columns as part of the function.
1144
+ """
1145
+ self.extract_config = extract_config
1146
+ self.reassign_columns = reassign_columns
1147
+
1148
+ def expand_node(
1149
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
1150
+ ) -> Collection[node.Node]:
1151
+ """Expands a node into multiple, given the extract_config passed to
1152
+ parameterize_extract_columns. Goes through all parameterizations,
1153
+ creates an extract_columns node for each, then delegates to that.
1154
+ Note this calls out to `@parameterize` and `@extract_columns` rather
1155
+ than reimplementing the logic.
1156
+
1157
+ :param node_: Node to expand
1158
+ :param config: Config to use to expand
1159
+ :param fn: Original function
1160
+ :return: The nodes produced by this decorator.
1161
+ """
1162
+ output_nodes = []
1163
+ for i, parameterization in enumerate(self.extract_config):
1164
+
1165
+ @functools.wraps(fn)
1166
+ def wrapper_fn(*args, _output_columns=parameterization.outputs, **kwargs):
1167
+ df_out = fn(*args, **kwargs)
1168
+ df_out.columns = _output_columns
1169
+ return df_out
1170
+
1171
+ new_node = node_.copy_with(callabl=wrapper_fn)
1172
+ fn_to_call = wrapper_fn if self.reassign_columns else fn
1173
+ # We have to rename the underlying function so that we do not
1174
+ # get naming collisions. Using __ is cleaner than using a uuid
1175
+ # as it is easier to read/manage and naturally maeks sense.
1176
+ parameterization_decorator = parameterize(
1177
+ **{node_.name + f"__{i}": parameterization.input_mapping}
1178
+ )
1179
+ (parameterized_node,) = parameterization_decorator.expand_node(
1180
+ new_node, config, fn_to_call
1181
+ )
1182
+ extract_columns_decorator = extract_columns(*parameterization.outputs)
1183
+ output_nodes.extend(
1184
+ extract_columns_decorator.transform_node(
1185
+ parameterized_node, config, parameterized_node.callable
1186
+ )
1187
+ )
1188
+
1189
+ return output_nodes
1190
+
1191
+ def validate(self, fn: Callable):
1192
+ extract_columns.validate_return_type(fn)
1193
+
1194
+
1195
+ class inject(parameterize):
1196
+ """@inject allows you to replace parameters with values passed in. You can think of
1197
+ it as a `@parameterize` call that has only one parameterization, the result of which
1198
+ is the name of the function. See the following examples:
1199
+
1200
+ .. code-block:: python
1201
+
1202
+ import pandas as pd
1203
+ from function_modifiers import inject, source, value, group
1204
+
1205
+ @inject(nums=group(source('a'), value(10), source('b'), value(2)))
1206
+ def a_plus_10_plus_b_plus_2(nums: List[int]) -> int:
1207
+ return sum(nums)
1208
+
1209
+ This would be equivalent to:
1210
+
1211
+ @parameterize(
1212
+ a_plus_10_plus_b_plus_2={
1213
+ 'nums': group(source('a'), value(10), source('b'), value(2))
1214
+ })
1215
+ def sum_numbers(nums: List[int]) -> int:
1216
+ return sum(nums)
1217
+
1218
+ Something to note -- we currently do not support the case in which the same parameter is utilized
1219
+ multiple times as an injection. E.G. two lists, a list and a dict, two sources, etc...
1220
+
1221
+ This is considered undefined behavior, and should be avoided.
1222
+ """
1223
+
1224
+ def __init__(self, **key_mapping: ParametrizedDependency):
1225
+ """Instantiates an @inject decorator with the given key_mapping.
1226
+
1227
+ :param key_mapping: A dictionary of string to dependency spec.
1228
+ This is the same as the input mapping in `@parameterize`.
1229
+ """
1230
+ super(inject, self).__init__(**{parameterize.PLACEHOLDER_PARAM_NAME: key_mapping})