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,1634 @@
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
+ from __future__ import annotations
19
+
20
+ import inspect
21
+ import logging
22
+ import typing
23
+ from collections import Counter, defaultdict
24
+ from collections.abc import Callable, Collection
25
+ from typing import Any, Union
26
+
27
+ import pandas as pd
28
+
29
+ from hamilton import models, node
30
+ from hamilton.dev_utils.deprecation import deprecated
31
+ from hamilton.function_modifiers import base
32
+ from hamilton.function_modifiers.configuration import ConfigResolver, hamilton_exclude
33
+ from hamilton.function_modifiers.delayed import resolve as delayed_resolve
34
+ from hamilton.function_modifiers.dependencies import (
35
+ LiteralDependency,
36
+ SingleDependency,
37
+ UpstreamDependency,
38
+ source,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ """Decorators that replace a function's execution with specified behavior"""
44
+
45
+ # Python 3.10 + has this built in, otherwise we have to define it
46
+ try:
47
+ from types import EllipsisType
48
+ except ImportError:
49
+ EllipsisType = type(...)
50
+
51
+
52
+ # the following are empty functions that we can compare against to ensure that @does uses an empty function
53
+ def _empty_function():
54
+ pass
55
+
56
+
57
+ def _empty_function_with_docstring():
58
+ """Docstring for an empty function"""
59
+ pass
60
+
61
+
62
+ def ensure_function_empty(fn: Callable):
63
+ """
64
+ Ensures that a function is empty. This is strict definition -- the function must have only one line (and
65
+ possibly a docstring), and that line must say "pass".
66
+ """
67
+ if fn.__code__.co_code not in {
68
+ _empty_function.__code__.co_code,
69
+ _empty_function_with_docstring.__code__.co_code,
70
+ }:
71
+ raise base.InvalidDecoratorException(
72
+ f'Function: {fn.__name__} is not empty. Must have only one line that consists of "pass"'
73
+ )
74
+
75
+
76
+ class does(base.NodeCreator):
77
+ """``@does`` is a decorator that essentially allows you to run a function over all the input parameters. \
78
+ So you can't pass any old function to ``@does``, instead the function passed has to take any amount of inputs and \
79
+ process them all in the same way.
80
+
81
+ .. code-block:: python
82
+
83
+ import pandas as pd
84
+ from hamilton.function_modifiers import does
85
+ import internal_package_with_logic
86
+
87
+ def sum_series(**series: pd.Series) -> pd.Series:
88
+ '''This function takes any number of inputs and sums them all together.'''
89
+ ...
90
+
91
+ @does(sum_series)
92
+ def D_XMAS_GC_WEIGHTED_BY_DAY(D_XMAS_GC_WEIGHTED_BY_DAY_1: pd.Series,
93
+ D_XMAS_GC_WEIGHTED_BY_DAY_2: pd.Series) -> pd.Series:
94
+ '''Adds D_XMAS_GC_WEIGHTED_BY_DAY_1 and D_XMAS_GC_WEIGHTED_BY_DAY_2'''
95
+ pass
96
+
97
+ @does(internal_package_with_logic.identity_function)
98
+ def copy_of_x(x: pd.Series) -> pd.Series:
99
+ '''Just returns x'''
100
+ pass
101
+
102
+ The example here is a function, that all that it does, is sum all the parameters together. So we can annotate it \
103
+ with the ``@does`` decorator and pass it the ``sum_series`` function. The ``@does`` decorator is currently limited \
104
+ to just allow functions that consist only of one argument, a generic \\*\\*kwargs.
105
+ """
106
+
107
+ def __init__(self, replacing_function: Callable, **argument_mapping: str | list[str]):
108
+ """Constructor for a modifier that replaces the annotated functions functionality with something else.
109
+ Right now this has a very strict validation requirements to make compliance with the framework easy.
110
+
111
+ :param replacing_function: The function to replace the original function with.
112
+ :param argument_mapping: A mapping of argument name in the replacing function to argument name in the \
113
+ decorating function.
114
+ """
115
+ self.replacing_function = replacing_function
116
+ self.argument_mapping = argument_mapping
117
+
118
+ @staticmethod
119
+ def map_kwargs(kwargs: dict[str, Any], argument_mapping: dict[str, str]) -> dict[str, Any]:
120
+ """Maps kwargs using the argument mapping.
121
+ This does 2 things:
122
+ 1. Replaces all kwargs in passed_in_kwargs with their mapping
123
+ 2. Injects all defaults from the origin function signature
124
+
125
+ :param kwargs: Keyword arguments that will be passed into a hamilton function.
126
+ :param argument_mapping: Mapping of those arguments to a replacing function's arguments.
127
+ :return: The new kwargs for the replacing function's arguments.
128
+ """
129
+ output = {**kwargs}
130
+ for arg_mapped_to, original_arg in argument_mapping.items():
131
+ if original_arg in kwargs and arg_mapped_to not in argument_mapping.values():
132
+ del output[original_arg]
133
+ # Note that if it is not there it could be a **kwarg
134
+ output[arg_mapped_to] = kwargs[original_arg]
135
+ return output
136
+
137
+ @staticmethod
138
+ def test_function_signatures_compatible(
139
+ fn_signature: inspect.Signature,
140
+ replace_with_signature: inspect.Signature,
141
+ argument_mapping: dict[str, str],
142
+ ) -> bool:
143
+ """Tests whether a function signature and the signature of the replacing function are compatible.
144
+
145
+ :param fn_signature:
146
+ :param replace_with_signature:
147
+ :param argument_mapping:
148
+ :return: True if they're compatible, False otherwise
149
+ """
150
+ # The easy (and robust) way to do this is to use the bind with a set of dummy arguments and test if it breaks.
151
+ # This way we're not reinventing the wheel.
152
+ SENTINEL_ARG_VALUE = ... # does not matter as we never use it
153
+ # We initialize as the default values, as they'll always be injected in
154
+ dummy_param_values = {
155
+ key: SENTINEL_ARG_VALUE
156
+ for key, param_spec in fn_signature.parameters.items()
157
+ if param_spec.default is not inspect.Parameter.empty
158
+ }
159
+ # Then we update with the dummy values. Again, replacing doesn't matter (we'll be mimicking it later)
160
+ dummy_param_values.update({key: SENTINEL_ARG_VALUE for key in fn_signature.parameters})
161
+ dummy_param_values = does.map_kwargs(dummy_param_values, argument_mapping)
162
+ try:
163
+ # Python signatures have a bind() capability which does exactly what we want to do
164
+ # Throws a type error if it is not valid
165
+ replace_with_signature.bind(**dummy_param_values)
166
+ except TypeError:
167
+ return False
168
+ return True
169
+
170
+ @staticmethod
171
+ def ensure_function_signature_compatible(
172
+ og_function: Callable,
173
+ replacing_function: Callable,
174
+ argument_mapping: dict[str, str],
175
+ ):
176
+ """Ensures that a function signature is compatible with the replacing function, given the argument mapping
177
+
178
+ :param og_function: Function that's getting replaced (decorated with `@does`)
179
+ :param replacing_function: A function that gets called in its place (passed in by `@does`)
180
+ :param argument_mapping: The mapping of arguments from fn to replace_with
181
+ :return:
182
+ """
183
+ fn_parameters = inspect.signature(og_function).parameters
184
+ invalid_fn_parameters = []
185
+ for param_name, param_spec in fn_parameters.items():
186
+ if param_spec.kind not in {
187
+ inspect.Parameter.KEYWORD_ONLY,
188
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
189
+ }:
190
+ invalid_fn_parameters.append(param_name)
191
+
192
+ if invalid_fn_parameters:
193
+ raise base.InvalidDecoratorException(
194
+ f"Decorated function for @does (and really, all of hamilton), "
195
+ f"can only consist of keyword-friendly arguments. "
196
+ f"The following parameters for {og_function.__name__} are not keyword-friendly: {invalid_fn_parameters}"
197
+ )
198
+ if not does.test_function_signatures_compatible(
199
+ inspect.signature(og_function),
200
+ inspect.signature(replacing_function),
201
+ argument_mapping,
202
+ ):
203
+ raise base.InvalidDecoratorException(
204
+ f"The following function signatures are not compatible for use with @does: "
205
+ f"{og_function.__name__} with signature {inspect.signature(og_function)} "
206
+ f"and replacing function {replacing_function.__name__} with signature {inspect.signature(replacing_function)}. "
207
+ f"Mapping for arguments provided was: {argument_mapping}. You can fix this by either adjusting "
208
+ f"the signature for the replacing function *or* adjusting the mapping."
209
+ )
210
+
211
+ def validate(self, fn: Callable):
212
+ """Validates that the function:
213
+ - Is empty (we don't want to be overwriting actual code)
214
+ - Has a compatible return type
215
+ - Matches the function signature with the appropriate mapping
216
+ :param fn: Function to validate
217
+ :raises: InvalidDecoratorException
218
+ """
219
+ ensure_function_empty(fn)
220
+ does.ensure_function_signature_compatible(
221
+ fn, self.replacing_function, self.argument_mapping
222
+ )
223
+
224
+ def generate_nodes(self, fn: Callable, config) -> list[node.Node]:
225
+ """Returns one node which has the replaced functionality
226
+ :param fn: Function to decorate
227
+ :param config: Configuration (not used in this)
228
+ :return: A node with the function in `@does` injected,
229
+ and the same parameters/types as the original function.
230
+ """
231
+
232
+ def wrapper_function(**kwargs):
233
+ final_kwarg_values = {
234
+ key: param_spec.default
235
+ for key, param_spec in inspect.signature(fn).parameters.items()
236
+ if param_spec.default is not inspect.Parameter.empty
237
+ }
238
+ final_kwarg_values.update(kwargs)
239
+ final_kwarg_values = does.map_kwargs(final_kwarg_values, self.argument_mapping)
240
+ return self.replacing_function(**final_kwarg_values)
241
+
242
+ return [node.Node.from_fn(fn).copy_with(callabl=wrapper_function)]
243
+
244
+
245
+ def get_default_tags(fn: Callable) -> dict[str, str]:
246
+ """Function that encapsulates default tags on a function.
247
+
248
+ :param fn: the function we want to create default tags for.
249
+ :return: a dictionary with str -> str values representing the default tags.
250
+ """
251
+ module_name = inspect.getmodule(fn).__name__
252
+ return {"module": module_name}
253
+
254
+
255
+ @deprecated(
256
+ warn_starting=(1, 20, 0),
257
+ fail_starting=(2, 0, 0),
258
+ use_this=delayed_resolve,
259
+ explanation="dynamic_transform has been replaced with @resolve -- a cleaner way"
260
+ "to utilize config for resolving decorators. Note this allows you to use any"
261
+ "existing decorators.",
262
+ current_version=(1, 19, 0),
263
+ migration_guide="https://hamilton.apache.org/reference/decorators/",
264
+ )
265
+ class dynamic_transform(base.NodeCreator):
266
+ def __init__(
267
+ self,
268
+ transform_cls: type[models.BaseModel],
269
+ config_param: str,
270
+ **extra_transform_params,
271
+ ):
272
+ """Constructs a model. Takes in a model_cls, which has to have a parameter."""
273
+ self.transform_cls = transform_cls
274
+ self.config_param = config_param
275
+ self.extra_transform_params = extra_transform_params
276
+
277
+ def validate(self, fn: Callable):
278
+ """Validates that the model works with the function -- ensures:
279
+ 1. function has no code
280
+ 2. function has no parameters
281
+ 3. function has series as a return type
282
+ :param fn: Function to validate
283
+ :raises InvalidDecoratorException if the model is not valid.
284
+ """
285
+
286
+ ensure_function_empty(fn) # it has to look exactly
287
+ signature = inspect.signature(fn)
288
+ if not issubclass(typing.get_type_hints(fn).get("return"), pd.Series):
289
+ raise base.InvalidDecoratorException(
290
+ "Models must declare their return type as a pandas Series"
291
+ )
292
+ if len(signature.parameters) > 0:
293
+ raise base.InvalidDecoratorException(
294
+ "Models must have no parameters -- all are passed in through the config"
295
+ )
296
+
297
+ def generate_nodes(self, fn: Callable, config: dict[str, Any] = None) -> list[node.Node]:
298
+ if self.config_param not in config:
299
+ raise base.InvalidDecoratorException(
300
+ f"Configuration has no parameter: {self.config_param}. Did you define it? If so did you spell it right?"
301
+ )
302
+ fn_name = fn.__name__
303
+ transform = self.transform_cls(
304
+ config[self.config_param], fn_name, **self.extra_transform_params
305
+ )
306
+ return [
307
+ node.Node(
308
+ name=fn_name,
309
+ typ=typing.get_type_hints(fn).get("return"),
310
+ doc_string=fn.__doc__,
311
+ callabl=transform.compute,
312
+ input_types={dep: pd.Series for dep in transform.get_dependents()},
313
+ tags=get_default_tags(fn),
314
+ )
315
+ ]
316
+
317
+ def require_config(self) -> list[str]:
318
+ """Returns the configuration parameters that this model requires
319
+
320
+ :return: Just the one config param used by this model
321
+ """
322
+ return [self.config_param]
323
+
324
+
325
+ class model(dynamic_transform):
326
+ """Model, same as a dynamic transform"""
327
+
328
+ def __init__(self, model_cls, config_param: str, **extra_model_params):
329
+ super(model, self).__init__(
330
+ transform_cls=model_cls, config_param=config_param, **extra_model_params
331
+ )
332
+
333
+
334
+ NamespaceType = Union[str, EllipsisType, None]
335
+
336
+
337
+ class Applicable:
338
+ """Applicable is a largely internal construct that represents a function that can be applied as a node.
339
+ A few of these function are external-facing, however (named, when, when_not, ...)"""
340
+
341
+ def __init__(
342
+ self,
343
+ fn: Callable | str | None,
344
+ args: tuple[Any | SingleDependency, ...],
345
+ kwargs: dict[str, Any | SingleDependency],
346
+ target_fn: Callable | str | None = None,
347
+ _resolvers: list[ConfigResolver] = None,
348
+ _name: str | None = None,
349
+ _namespace: str | None | EllipsisType = ...,
350
+ _target: base.TargetType = None,
351
+ ):
352
+ """Instantiates an Applicable.
353
+
354
+ We allow fn=None for the use-cases where we want to store the Applicable config (i.e. .when* family, namespace, target, etc.)
355
+ but do not yet the access to the actual function we are turning into the Applicable. In addition, in case the target nodes come
356
+ from a function (using extract_columns/extract_fields) we can pass target_fn to have access to its pointer that we can decorate
357
+ programmatically. See `apply_to` and `mutate` for an example.
358
+
359
+ :param args: Args (*args) to pass to the function
360
+ :param fn: Function it takes in. Can be None to create an Applicable placeholder with delayed choice of function.
361
+ :param target_fn: Function the applicable will be applied to
362
+ :param _resolvers: Resolvers to use for the function
363
+ :param _name: Name of the node to be created
364
+ :param _namespace: Namespace of the node to be created -- currently only single-level namespaces are supported
365
+ :param _target: Selects which target nodes it will be appended onto. Default None gets resolved on decorator level.
366
+ Specifically, pipe_input would use the first parameter and pipe_output / mutate would apply it to all sink nodes.
367
+ :param kwargs: Kwargs (**kwargs) to pass to the function
368
+ """
369
+
370
+ if isinstance(fn, str) or isinstance(target_fn, str):
371
+ raise TypeError("Strings are not supported currently. Please provide function pointer.")
372
+
373
+ self.fn = fn
374
+ self.target_fn = target_fn
375
+
376
+ if "_name" in kwargs:
377
+ raise ValueError("Cannot pass in _name as a kwarg")
378
+
379
+ self.kwargs = {key: value for key, value in kwargs.items() if key != "__name"} # TODO --
380
+ self.args = args
381
+ # figure out why this was showing up in two places...
382
+ self.resolvers = _resolvers if _resolvers is not None else []
383
+ self.name = _name
384
+ self.namespace = _namespace
385
+ self.target = _target
386
+
387
+ def _with_resolvers(self, *additional_resolvers: ConfigResolver) -> "Applicable":
388
+ """Helper function for the .when* group"""
389
+ return Applicable(
390
+ fn=self.fn,
391
+ _resolvers=self.resolvers + list(additional_resolvers),
392
+ _name=self.name,
393
+ _namespace=self.namespace,
394
+ _target=self.target,
395
+ args=self.args,
396
+ kwargs=self.kwargs,
397
+ target_fn=self.target_fn,
398
+ )
399
+
400
+ def when(self, **key_value_pairs) -> "Applicable":
401
+ """Choose to apply this function when all of the keys in the function
402
+ are present in the config, and the values match the values in the config.
403
+
404
+ :param key_value_pairs: key/value pairs to match
405
+ :return: The Applicable with this condition applied
406
+ """
407
+ return self._with_resolvers(ConfigResolver.when(**key_value_pairs))
408
+
409
+ def when_not(self, **key_value_pairs) -> "Applicable":
410
+ """Choose to apply this function when all of the keys specified
411
+ do not match the values specified in the config.
412
+
413
+ :param key_value_pairs: key/value pairs to match
414
+ :return: The Applicable with this condition applied
415
+ """
416
+ return self._with_resolvers(ConfigResolver.when_not(**key_value_pairs))
417
+
418
+ def when_in(self, **key_value_group_pairs: list) -> "Applicable":
419
+ """Choose to apply this function when all the keys provided have values contained within the list of values
420
+ specified
421
+
422
+ :param key_value_group_pairs: key/value pairs to match
423
+ :return: The Applicable with this condition applied
424
+ """
425
+ return self._with_resolvers(ConfigResolver.when_in(**key_value_group_pairs))
426
+
427
+ def when_not_in(self, **key_value_group_pairs: list) -> "Applicable":
428
+ """Choose to apply this function when all the keys provided have values not contained within the list of values
429
+ specified.
430
+
431
+ :param key_value_group_pairs: key/value pairs to match
432
+ :return: The Applicable with this condition applied
433
+ """
434
+
435
+ return self._with_resolvers(ConfigResolver.when_not_in(**key_value_group_pairs))
436
+
437
+ def namespaced(self, namespace: NamespaceType) -> "Applicable":
438
+ """Add a namespace to this node. You probably don't need this -- you should look at "named" instead.
439
+
440
+ :param namespace: Namespace to apply, can be ..., None, or a string.
441
+ :return: The Applicable with this namespace
442
+ """
443
+ return Applicable(
444
+ fn=self.fn,
445
+ _resolvers=self.resolvers,
446
+ _name=self.name,
447
+ _namespace=namespace,
448
+ _target=self.target,
449
+ args=self.args,
450
+ kwargs=self.kwargs,
451
+ target_fn=self.target_fn,
452
+ )
453
+
454
+ def resolves(self, config: dict[str, Any]) -> bool:
455
+ """Returns whether the Applicable resolves with the given config
456
+
457
+ :param config: Configuration to check
458
+ :return: Whether the Applicable resolves with the given config
459
+ """
460
+ for resolver in self.resolvers:
461
+ if not resolver(config):
462
+ return False
463
+ return True
464
+
465
+ def named(self, name: str, namespace: NamespaceType = ...) -> "Applicable":
466
+ """Names the function application. This has the following rules:
467
+ 1. The name will be the name passed in, this is required
468
+ 2. If the namespace is `None`, then there will be no namespace
469
+ 3. If the namespace is `...`, then the namespace will be the namespace that already exists, usually the name of the
470
+ function that this is decorating. This is an odd case -- but it helps if you have
471
+ multiple of the same type of operations that you want to apply across different nodes,
472
+ or in the case of a parameterization (which is not yet supported).
473
+
474
+ :param name: Name of the node to be created
475
+ :param namespace: Namespace of the node to be created -- currently only single-level namespaces are supported
476
+ :return: The applicable with the new name
477
+ """
478
+ return Applicable(
479
+ fn=self.fn,
480
+ _resolvers=self.resolvers,
481
+ _name=name if name is not None else self.name,
482
+ _namespace=(
483
+ None
484
+ if namespace is None
485
+ else (namespace if namespace is not ... else self.namespace)
486
+ ),
487
+ _target=self.target,
488
+ args=self.args,
489
+ kwargs=self.kwargs,
490
+ target_fn=self.target_fn,
491
+ )
492
+
493
+ # on_input / on_output are the same but here for naming convention
494
+ # I know there is a way to dynamically resolve this to revert to a common function
495
+ # just can't remember it now or find it online...
496
+ # TODO: adding the option to select target parameter for each transform
497
+ # def on_input(self, target: base.TargetType) -> "Applicable":
498
+ # """Add Target on a single function level.
499
+
500
+ # This determines to which node(s) it will applies. Should match the same naming convention
501
+ # as the NodeTransorfmLifecycle child class (for example NodeTransformer).
502
+
503
+ # :param target: Which node(s) to apply on top of
504
+ # :return: The Applicable with specified target
505
+ # """
506
+ # return Applicable(
507
+ # fn=self.fn,
508
+ # _resolvers=self.resolvers,
509
+ # _name=self.name,
510
+ # _namespace=self.namespace,
511
+ # _target=target if target is not None else self.target,
512
+ # args=self.args,
513
+ # kwargs=self.kwargs,
514
+ # target_fn=self.target_fn,
515
+ # )
516
+
517
+ def on_output(self, target: base.TargetType) -> "Applicable":
518
+ """Add Target on a single function level.
519
+
520
+ This determines to which node(s) it will applies. Should match the same naming convention
521
+ as the NodeTransorfmLifecycle child class (for example NodeTransformer).
522
+
523
+ :param target: Which node(s) to apply on top of
524
+ :return: The Applicable with specified target
525
+ """
526
+ return Applicable(
527
+ fn=self.fn,
528
+ _resolvers=self.resolvers,
529
+ _name=self.name,
530
+ _namespace=self.namespace,
531
+ _target=target if target is not None else self.target,
532
+ args=self.args,
533
+ kwargs=self.kwargs,
534
+ target_fn=self.target_fn,
535
+ )
536
+
537
+ def get_config_elements(self) -> list[str]:
538
+ """Returns the config elements that this Applicable uses"""
539
+ out = []
540
+ for resolver in self.resolvers:
541
+ out.extend(resolver.optional_config)
542
+ return out
543
+
544
+ def validate(self, chain_first_param: bool, allow_custom_namespace: bool):
545
+ """Validates that the Applicable function can be applied given the
546
+ set of args/kwargs passed in. This says that:
547
+
548
+ 1. The signature binds appropriately
549
+ 2. If we chain the first parameter, it is not present in the function
550
+
551
+ Note that this is currently restrictive. We only support hamilton-friendly functions. Furthermore,
552
+ this logic is slightly duplicated from `@does` above. We will be suporting more function shapes (in
553
+ both this and `@does`) over time, and also combining the logic between the two for
554
+ validating/binding signatures.
555
+
556
+ :param chain_first_param: Whether we chain the first parameter
557
+ :raises InvalidDecoratorException if the function cannot be applied
558
+ :return:
559
+ """
560
+ args = ((...,) if chain_first_param else ()) + tuple(self.args) # dummy argument at first
561
+ sig = inspect.signature(self.fn)
562
+ if len(sig.parameters) == 0:
563
+ raise base.InvalidDecoratorException(
564
+ f"Function: {self.fn.__name__} has no parameters. "
565
+ f"You cannot apply a function with no parameters."
566
+ )
567
+ invalid_args = [
568
+ item
569
+ for item in inspect.signature(self.fn).parameters.values()
570
+ if item.kind
571
+ not in {
572
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
573
+ inspect.Parameter.KEYWORD_ONLY,
574
+ }
575
+ ]
576
+ if len(invalid_args) > 0:
577
+ raise base.InvalidDecoratorException(
578
+ f"Function: {self.fn.__name__} has invalid parameters. "
579
+ "You cannot apply a function with parameters that are not keyword-friendly. "
580
+ f"The following parameters are not keyword-friendly: {invalid_args}"
581
+ )
582
+ try:
583
+ sig.bind(*args, **self.kwargs)
584
+ except TypeError as e:
585
+ raise base.InvalidDecoratorException(
586
+ f"Function: {self.fn.__name__} cannot be applied with the following args: {self.args} "
587
+ f"and the following kwargs: {self.kwargs}"
588
+ ) from e
589
+ if len(sig.parameters) == 0:
590
+ raise base.InvalidDecoratorException(
591
+ f"Function: {self.fn.__name__} has no parameters. "
592
+ "You cannot apply a function with no parameters."
593
+ )
594
+ if self.namespace is not ... and not allow_custom_namespace:
595
+ raise base.InvalidDecoratorException(
596
+ "Currently, setting namespace globally inside "
597
+ "pipe(...)/flow(...) is not compatible with setting namespace "
598
+ "for a step(...) call."
599
+ )
600
+ try:
601
+ node.Node.from_fn(self.fn)
602
+ except ValueError as e:
603
+ raise base.InvalidDecoratorException(
604
+ f"Function: {self.fn.__name__} cannot be applied with the following args: {self.args} "
605
+ f"and the following kwargs: {self.kwargs}. See documentation on pipe(), the function "
606
+ "shapes are currently restrictive to anything with named kwargs (either kwarg-only or positional/kwarg arguments), "
607
+ "and must be typed. If you need functions that don't have these requirements, please reach out to the Hamilton team."
608
+ "Current workarounds are to define a wrapper function that assigns types with the proper keyword-friendly arguments."
609
+ ) from e
610
+
611
+ def resolve_namespace(self, default_namespace: str) -> tuple[str, ...]:
612
+ """Resolves the namespace -- see rules in `named` for more details.
613
+
614
+ :param default_namespace: namespace to use as a default if we do not wish to override it
615
+ :return: The namespace to use, as a tuple (hierarchical)
616
+ """
617
+ return (
618
+ (default_namespace,)
619
+ if self.namespace is ...
620
+ else (self.namespace,)
621
+ if self.namespace is not None
622
+ else ()
623
+ )
624
+
625
+ def bind_function_args(
626
+ self, current_param: str | None
627
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
628
+ """Binds function arguments, given current, chained parameter
629
+
630
+ :param current_param: Current, chained parameter. None, if we're not chaining.
631
+ :return: A tuple of (upstream_inputs, literal_inputs)
632
+ """
633
+ args_to_bind = self.args
634
+ if current_param is not None:
635
+ args_to_bind = (source(current_param),) + args_to_bind
636
+ kwargs_to_bind = self.kwargs
637
+ fn_signature = inspect.signature(self.fn)
638
+ bound_signature = fn_signature.bind(*args_to_bind, **kwargs_to_bind)
639
+ all_kwargs = {**bound_signature.arguments, **bound_signature.kwargs}
640
+ upstream_inputs = {}
641
+ literal_inputs = {}
642
+ # TODO -- restrict to ensure that this covers *all* dependencies
643
+ # TODO -- bind to parameters using args
644
+ for dep, value in all_kwargs.items():
645
+ if isinstance(value, UpstreamDependency):
646
+ upstream_inputs[dep] = value.source
647
+ elif isinstance(value, LiteralDependency):
648
+ literal_inputs[dep] = value.value
649
+ else:
650
+ literal_inputs[dep] = value
651
+
652
+ return upstream_inputs, literal_inputs
653
+
654
+
655
+ def step(fn, *args: SingleDependency | Any, **kwargs: SingleDependency | Any) -> Applicable:
656
+ """Applies a function to for a node (or a subcomponent of a node).
657
+ See documentation for `pipe` to see how this is used.
658
+
659
+ :param fn: Function to use. Must be validly called as f(**kwargs), and have a 1:1 mapping of kwargs to parameters.
660
+ :param args: Args to pass to the function -- although these cannot be variable/position-only arguments, they can be
661
+ positional arguments. If these are not source/value, they will be converted to a value (a literal)
662
+ :param kwargs: Kwargs to pass to the function. These can be source/value, or they can be literals. If they are literals,
663
+ they will be converted to a value (a literal)
664
+ :return: an applicable with the function applied
665
+ """
666
+ return Applicable(fn=fn, _resolvers=[], args=args, kwargs=kwargs)
667
+
668
+
669
+ # TODO: In case of multiple parameter targets we want to safeguard that it is a clear target distribution
670
+ # class MissingTargetError(Exception):
671
+ # """When setting target make sure it is clear which transform targets which node.
672
+
673
+ # This is a safeguard, because the default behavior may not apply if targets are partially set
674
+ # and we do not want to make assumptions what the user meant.
675
+ # """
676
+
677
+ # pass
678
+
679
+
680
+ class pipe_input(base.NodeInjector):
681
+ """Running a series of transformations on the input of the function.
682
+
683
+ To demonstrate the rules for chaining nodes, we'll be using the following example. This is
684
+ using primitives to demonstrate, but as hamilton is just functions of any python objects, this works perfectly with
685
+ dataframes, series, etc...
686
+
687
+
688
+ .. code-block:: python
689
+ :name: Simple @pipe_input example
690
+
691
+ from hamilton.function_modifiers import step, pipe_input, value, source
692
+
693
+
694
+ def _add_one(x: int) -> int:
695
+ return x + 1
696
+
697
+
698
+ def _sum(x: int, y: int) -> int:
699
+ return x + y
700
+
701
+
702
+ def _multiply(x: int, y: int, z: int = 10) -> int:
703
+ return x * y * z
704
+
705
+
706
+ @pipe_input(
707
+ step(_add_one),
708
+ step(_multiply, y=2),
709
+ step(_sum, y=value(3)),
710
+ step(_multiply, y=source("upstream_node_to_multiply")),
711
+ )
712
+ def final_result(upstream_int: int) -> int:
713
+ return upstream_int
714
+
715
+ .. code-block:: python
716
+ :name: Equivalent example with no @pipe_input, nested
717
+
718
+ upstream_int = ... # result from upstream
719
+ upstream_node_to_multiply = ... # result from upstream
720
+
721
+ output = final_result(
722
+ _multiply(
723
+ _sum(
724
+ _multiply(
725
+ _add_one(upstream_int),
726
+ y=2
727
+ ),
728
+ y=3
729
+ ),
730
+ y=upstream_node_to_multiply
731
+ )
732
+ )
733
+
734
+ .. code-block:: python
735
+ :name: Equivalent example with no @pipe_input, procedural
736
+
737
+ upstream_int = ... # result from upstream
738
+ upstream_node_to_multiply = ... # result from upstream
739
+
740
+ one_added = _add_one(upstream_int)
741
+ multiplied = _multiply(one_added, y=2)
742
+ summed = _sum(multiplied, y=3)
743
+ multiplied_again = _multiply(summed, y=upstream_node_to_multiply)
744
+ output = final_result(multiplied_again)
745
+
746
+
747
+ Note that functions must have no position-only arguments (this is rare in python, but hamilton does not handle these).
748
+ This basically means that the functions must be defined similarly to ``def fn(x, y, z=10)`` and not ``def fn(x, y, /, z=10)``.
749
+ In fact, all arguments must be named and "kwarg-friendly", meaning that the function can happily be called with ``**kwargs``,
750
+ where kwargs are some set of resolved upstream values. So, no ``*args`` are allowed, and ``**kwargs`` (variable keyword-only) are not
751
+ permitted. Note that this is not a design limitation, rather an implementation detail -- if you feel like you need this, please
752
+ reach out.
753
+
754
+ Furthermore, the function should be typed, as a Hamilton function would be.
755
+
756
+ One has three ways to tune the shape/implementation of the subsequent nodes:
757
+
758
+ 1. ``when``/``when_not``/``when_in``/``when_not_in`` -- these are used to filter the application of the function.
759
+ This is valuable to reflect if/else conditions in the structure of the DAG, pulling it out of functions, rather
760
+ than buried within the logic itself. It is functionally equivalent to ``@config.when``.
761
+
762
+ For instance, if you want to include a function in the chain only when a config parameter is set to a certain value, you can do:
763
+
764
+ .. code-block:: python
765
+
766
+ @pipe_input(
767
+ step(_add_one).when(foo="bar"),
768
+ step(_add_two, y=source("other_node_to_add").when(foo="baz"),
769
+ )
770
+ def final_result(upstream_int: int) -> int:
771
+ return upstream_int
772
+
773
+ This will only apply the first function when the config parameter ``foo`` is set to ``bar``, and the second when it is set to ``baz``.
774
+
775
+ 2. ``named`` -- this is used to name the node. This is useful if you want to refer to intermediate results.
776
+ If this is left out, hamilton will automatically name the functions in a globally unique manner. The names of
777
+ these functions will not necessarily be stable/guaranteed by the API, so if you want to refer to them, you should use ``named``.
778
+ The default namespace will always be the name of the decorated function (which will be the last node in the chain).
779
+
780
+ ``named`` takes in two parameters -- required is the ``name`` -- this will assign the nodes with a single name and *no* global namespace.
781
+ For instance:
782
+
783
+ .. code-block:: python
784
+
785
+ @pipe_input(
786
+ step(_add_one).named("a"),
787
+ step(_add_two, y=source("upstream_node")).named("b"),
788
+ )
789
+ def final_result(upstream_int: int) -> int:
790
+ return upstream_int
791
+
792
+ The above will create two nodes, ``a`` and ``b``. ``a`` will be the result of ``_add_one``, and ``b`` will be the result of ``_add_two``.
793
+ ``final_result`` will then be called with the output of ``b``. Note that, if these are part of a namespaced operation (a subdag, in particular),
794
+ they *will* get the same namespace as the subdag.
795
+
796
+ The second parameter is ``namespace``. This is used to specify a namespace for the node. This is useful if you want
797
+ to either (a) ensure that the nodes are namespaced but share a common one to avoid name clashes (usual case), or (b)
798
+ if you want a custom namespace (unusual case). To indicate a custom namespace, one need simply pass in a string.
799
+
800
+ To indicate that a node should share a namespace with the rest of the step(...) operations in a pipe, one can pass in ``...`` (the ellipsis).
801
+
802
+ .. code-block:: python
803
+ :name: Namespaced step
804
+
805
+
806
+ @pipe_input(
807
+ step(_add_one).named("a", namespace="foo"), # foo.a
808
+ step(_add_two, y=source("upstream_node")).named("b", namespace=...), # final_result.b
809
+ )
810
+ def final_result(upstream_int: int) -> int:
811
+ return upstream_int
812
+
813
+ Note that if you pass a namespace argument to the ``pipe_input`` function, it will set the namespace on each step operation.
814
+ This is useful if you want to ensure that all the nodes in a pipe have a common namespace, but you want to rename them.
815
+
816
+ .. code-block:: python
817
+ :name: pipe_input with globally applied namespace
818
+
819
+ @pipe_input(
820
+ step(_add_one).named("a"), # a
821
+ step(_add_two, y=source("upstream_node")).named("b"), # foo.b
822
+ namespace=..., # default -- final_result.a and final_result.b, OR
823
+ namespace=None, # no namespace -- a and b are exposed as that, OR
824
+ namespace="foo", # foo.a and foo.b
825
+ )
826
+ def final_result(upstream_int: int) -> int:
827
+ return upstream_int
828
+
829
+ In all likelihood, you should not be using this, and this is only here in case you want to expose a node for
830
+ consumption/output later. Setting the namespace in individual nodes as well as in ``pipe_input`` is not yet supported.
831
+
832
+ 3. ``on_input`` -- this selects which input we will run the pipeline on.
833
+ In case ``on_input`` is set to None (default), we apply ``pipe_input`` on the first parameter. Let us know if you wish to expand to other use-cases.
834
+ You can track the progress on this topic via: https://github.com/apache/hamilton/issues/1177
835
+
836
+ The following would apply function *_add_one* and *_add_two* to ``p2``:
837
+
838
+ .. code-block:: python
839
+
840
+ @pipe_input(
841
+ step(_add_one)
842
+ step(_add_two, y=source("upstream_node")),
843
+ on_input = "p2"
844
+ )
845
+ def final_result(p1: int, p2: int, p3: int) -> int:
846
+ return upstream_int
847
+
848
+ .. |
849
+ THIS IS COMMENTED OUT, I.E. SPHINX WILL NOT AUTODOC IT, HERE IN CASE WE ENABLE MULTIPLE PARAMETER TARGETS
850
+ For extra control in case of multiple function arguments (parameters), we can also specify the target parameter that we wish to transform.
851
+ In case ``on_input`` is set to None (default), we apply ``pipe_input`` on the first parameter only. If ``on_input`` is set for a specific transform
852
+ make sure the other ones are also set either through a global setting or individually, otherwise it is unclear which transforms target which parameters.
853
+
854
+ The following applies *_add_one* to ``p1``, ``p3`` and *_add_two* to ``p2``
855
+
856
+ .. code-block:: python
857
+
858
+ @pipe_input(
859
+ step(_add_one).on_input(["p1","p3"])
860
+ step(_add_two, y=source("upstream_node")).on_input("p2")
861
+ )
862
+ def final_result(p1: int, p2: int, p3: int) -> int:
863
+ return p1 + p2 + p3
864
+
865
+ We can also do this on the global level to set for all transforms a target parameter.
866
+
867
+ Lastly, a mixture of global and local is possible, where the global selects the target parameters for
868
+ all transforms and we can select individual transforms to also target more parameters.
869
+ The following would apply function *_add_one* to all ``p1``, ``p2``, ``p3`` and *_add_two* also on ``p2``
870
+
871
+ .. code-block:: python
872
+
873
+ @pipe_input(
874
+ step(_add_one).on_input(["p1","p3"])
875
+ step(_add_two, y=source("upstream_node")),
876
+ on_input = "p2"
877
+ )
878
+ def final_result(p1: int, p2: int, p3: int) -> int:
879
+ return upstream_int
880
+ | replace:: \
881
+ """
882
+
883
+ def __init__(
884
+ self,
885
+ *transforms: Applicable,
886
+ namespace: NamespaceType = ...,
887
+ on_input: base.TargetType = None,
888
+ collapse=False,
889
+ _chain=False,
890
+ ):
891
+ """Instantiates a ``@pipe_input`` decorator.
892
+
893
+ :param transforms: step transformations to be applied, in order
894
+ :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside ``pipe_input()``...
895
+ :param on_input: setting the target parameter for all steps in the pipe. Leave empty to select only the first argument.
896
+ :param collapse: Whether to collapse this into a single node. This is not currently supported.
897
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
898
+ """
899
+ if on_input is not None:
900
+ if not isinstance(on_input, str):
901
+ raise NotImplementedError(
902
+ "on_input currently only supports a single target parameter specified by a string. "
903
+ "Please reach out if you want a more flexible option in the feature."
904
+ )
905
+ base.NodeTransformer._early_validate_target(target=on_input, allow_multiple=True)
906
+
907
+ self.transforms = transforms
908
+ self.collapse = collapse
909
+ self.chain = _chain
910
+ self.namespace = namespace
911
+ self.target = [on_input]
912
+
913
+ # TODO: for multiple target parameter case
914
+ # if isinstance(on_input, str): # have to do extra since strings are collections in python
915
+ # self.target = [on_input]
916
+ # elif isinstance(on_input, Collection):
917
+ # self.target = on_input
918
+ # else:
919
+ # self.target = [on_input]
920
+
921
+ if self.collapse:
922
+ raise NotImplementedError(
923
+ "Collapsing step() functions as one node is not yet implemented for pipe_input(). Please reach out if you want this feature."
924
+ )
925
+
926
+ if self.chain:
927
+ raise NotImplementedError("@flow() is not yet supported -- this is ")
928
+
929
+ def _distribute_transforms_to_parameters(
930
+ self, params: dict[str, type[type]]
931
+ ) -> dict[str, list[Applicable]]:
932
+ """Resolves target option on the transform level.
933
+ Adds option that we can decide for each applicable which input parameter it will target on
934
+ top of the global target (if it is set).
935
+
936
+ We create a hash for each target parameter with a list of transforms that will be applied
937
+ to this parameter.
938
+
939
+ :params: Available input parameters of the function
940
+ :return: A dictionary mapping between selected parameters and list of transforms
941
+ """
942
+
943
+ selected_transforms = defaultdict(list)
944
+ for param in params:
945
+ if param in self.target:
946
+ selected_transforms[param].extend(self.transforms)
947
+ # TODO: in case of multiple parameters we can set individual targets and resolve them here
948
+ # for transform in self.transforms:
949
+ # target = transform.target
950
+ # # In case there is no target set on applicable we assign global target
951
+ # if target is None:
952
+ # target = self.target
953
+ # elif isinstance(target, str): # user selects single target via string
954
+ # target = [target]
955
+ # target.extend(self.target)
956
+ # elif isinstance(target, Collection): # user inputs a list of targets
957
+ # target.extend(self.target)
958
+
959
+ # if param in target:
960
+ # selected_transforms[param].append(transform)
961
+
962
+ return selected_transforms
963
+
964
+ def _create_valid_parameters_transforms_mapping(
965
+ self, mapping: dict[str, list[Applicable]], fn: Callable, params: dict[str, type[type]]
966
+ ) -> dict[str, list[Applicable]]:
967
+ """Checks for a valid distribution of transforms to parameters."""
968
+ sig = inspect.signature(fn)
969
+ param_names = []
970
+ for param in list(sig.parameters.values()):
971
+ param_names.append(param.name)
972
+ # use the name of the parameter to determine the first node
973
+ # Then wire them all through in order
974
+ # if it resolves, great
975
+ # if not, skip that, pointing to the previous
976
+ # Create a node along the way
977
+
978
+ if not mapping:
979
+ # This reverts back to legacy chaining through first parameter and checks first parameter
980
+ first_parameter = param_names[0]
981
+ if first_parameter not in params:
982
+ raise base.InvalidDecoratorException(
983
+ f"Function: {fn.__name__} has a first parameter that is not a dependency. "
984
+ f"@pipe requires the parameter names to match the function parameters. "
985
+ f"Thus it might not be compatible with some other decorators"
986
+ )
987
+ mapping[first_parameter] = self.transforms
988
+ # TODO: validate that all transforms have a target in case multiple parameters targeted
989
+ # else:
990
+ # # in case we set target this checks that each transform has at least one target parameter
991
+ # transform_set = []
992
+ # for param in mapping:
993
+ # transform_set.extend(mapping[param])
994
+ # transform_set = set(transform_set)
995
+ # if len(transform_set) != len(self.transforms):
996
+ # raise MissingTargetError(
997
+ # "The on_input settings are unclear. Please make sure all transforms "
998
+ # "either have specified individually or globally a target or there is "
999
+ # "no on_input usage."
1000
+ # )
1001
+
1002
+ # similar to above we check that the target parameter is among the actual function parameters
1003
+ if next(iter(mapping)) not in param_names:
1004
+ raise base.InvalidDecoratorException(
1005
+ f"Function: {fn.__name__} with parameters {param_names} does not a have "
1006
+ f"dependency {next(iter(mapping))}. @pipe_input requires the parameter "
1007
+ f"names to match the function parameters. Thus it might not be compatible "
1008
+ f"with some other decorators."
1009
+ )
1010
+
1011
+ return mapping
1012
+
1013
+ def _resolve_namespace(
1014
+ self,
1015
+ param: str,
1016
+ ) -> str:
1017
+ """Add parameter name to namespace.
1018
+ In case we pipe_input on multiple parameters we have to duplicate nodes to be able to chain
1019
+ them together for each argument and they have to have different names.
1020
+ """
1021
+ if self.namespace is ... or self.namespace is None:
1022
+ return param
1023
+ else:
1024
+ return f"{self.namespace}_{param}"
1025
+
1026
+ def inject_nodes(
1027
+ self, params: dict[str, type[type]], config: dict[str, Any], fn: Callable
1028
+ ) -> tuple[list[node.Node], dict[str, str]]:
1029
+ """Injects nodes into the graph. This creates a node for each pipe() step,
1030
+ then reassigns the inputs to pass it in."""
1031
+
1032
+ parameters_transforms_mapping = self._distribute_transforms_to_parameters(params=params)
1033
+ parameters_transforms_mapping = self._create_valid_parameters_transforms_mapping(
1034
+ mapping=parameters_transforms_mapping, fn=fn, params=params
1035
+ )
1036
+
1037
+ total_nodes = []
1038
+ total_rename_maps = {}
1039
+
1040
+ for param in parameters_transforms_mapping:
1041
+ # If only single parameter we revert to previous namespace convention since no duplication issues
1042
+ # This also ensures backwards compatibility
1043
+ if len(parameters_transforms_mapping) == 1:
1044
+ namespace = self.namespace
1045
+ else:
1046
+ namespace = self._resolve_namespace(param=param)
1047
+
1048
+ # Chaining gets done by linking the specified argument of each node
1049
+ nodes, current_param = chain_transforms(
1050
+ target_arg=param,
1051
+ transforms=parameters_transforms_mapping[param],
1052
+ namespace=namespace,
1053
+ config=config,
1054
+ fn=fn,
1055
+ )
1056
+ total_nodes.extend(nodes)
1057
+ total_rename_maps.update({param: current_param})
1058
+
1059
+ return total_nodes, total_rename_maps # rename to ensure it all works
1060
+
1061
+ def validate(self, fn: Callable):
1062
+ """Validates the the individual steps work together."""
1063
+ for applicable in self.transforms:
1064
+ applicable.validate(
1065
+ chain_first_param=True, allow_custom_namespace=self.namespace is ...
1066
+ )
1067
+ # TODO -- validate that the types match on the chain (this is de-facto done later)
1068
+
1069
+ def optional_config(self) -> dict[str, Any]:
1070
+ """Declares the optional configuration keys for this decorator.
1071
+ These are configuration keys that can be used by the decorator, but are not required.
1072
+ Along with these we have *defaults*, which we will use to pass to the config.
1073
+
1074
+ :return: The optional configuration keys with defaults. Note that this will return None
1075
+ if we have no idea what they are, which bypasses the configuration filtering we use entirely.
1076
+ This is mainly for the legacy API.
1077
+ """
1078
+ out = {}
1079
+ for applicable in self.transforms:
1080
+ for resolver in applicable.resolvers:
1081
+ out.update(resolver.optional_config)
1082
+ return out
1083
+
1084
+
1085
+ @deprecated(
1086
+ warn_starting=(1, 20, 0),
1087
+ fail_starting=(2, 0, 0),
1088
+ use_this=pipe_input,
1089
+ explanation="pipe has been replaced with pipe_input -- a clearer name since "
1090
+ "we also added pipe_output with complimentary functionality.",
1091
+ current_version=(1, 77, 0),
1092
+ migration_guide="https://hamilton.apache.org/reference/decorators/",
1093
+ )
1094
+ class pipe(pipe_input):
1095
+ def __init__(
1096
+ self,
1097
+ *transforms: Applicable,
1098
+ namespace: NamespaceType = ...,
1099
+ on_input: base.TargetType = None,
1100
+ collapse=False,
1101
+ _chain=False,
1102
+ ):
1103
+ super(pipe, self).__init__(
1104
+ *transforms,
1105
+ namespace=namespace,
1106
+ on_input=on_input,
1107
+ collapse=False,
1108
+ _chain=False,
1109
+ )
1110
+
1111
+
1112
+ # # TODO -- implement flow!
1113
+ # class flow(pipe):
1114
+ # """flow() is a more flexible, power-user version of `pipe`. The rules are largely similar, with a few key differences:
1115
+ #
1116
+ # 1. The first parameter is not passed through -- the user is responsible for passing all parameters into a function
1117
+ # 2. The final function can depend on any of the prior functions -- it will declare those as inputs using the input parameters. These will
1118
+ # be seen as inputs, regardless of namespace.
1119
+ #
1120
+ # This means that `flow` can be used to construct *any* DAG -- this is why its a power-user capability. Before we dig into some examples,
1121
+ # a quick note on when to use it:
1122
+ #
1123
+ # flow is meant to procedurally specify a subcomponent of the DAG. While Hamilton encourage declarative (not procedural) DAGs, there are
1124
+ # certain cases where you may find yourself wanting to more dynamically construct a DAG, for certain subcomponents. This is where
1125
+ # `flow` comes in. As we will show later in this doc, this can be very powerful when combined with `resolve` to build configuration-driven
1126
+ # DAGs. Again, however, this is meant to be a subset of a declarative DAG -- procedurally defined subdags can help with flexibility, but
1127
+ # should not be overused.
1128
+ #
1129
+ # Now, let's get to some examples:
1130
+ #
1131
+ # TODO -- basic example
1132
+ #
1133
+ # TODO -- example with resolve
1134
+ #
1135
+ # TODO -- examples with namespacing
1136
+ # """
1137
+ #
1138
+ # def __init__(self, *transforms: Applicable, collapse=False):
1139
+ # super(flow, self).__init__(*transforms, collapse=collapse, _chain=False)
1140
+
1141
+
1142
+ class SingleTargetError(Exception):
1143
+ """We prohibit the target to be raise both globally and locally.
1144
+
1145
+ Decorators that transform the output of a node can be set to transform only
1146
+ a certain output node (useful with extract_columns / extract_fields). Some decorators
1147
+ can group multiple transforms and we can set that certain output node either for all of them
1148
+ or for each individually.
1149
+
1150
+ This is a safeguard, because when you set the global target it creates a subset of those nodes and
1151
+ if the local target is outside of that subset it gets ignore (opposed to the logical assumption that
1152
+ it can override the global one). So we disable that case.
1153
+ """
1154
+
1155
+ pass
1156
+
1157
+
1158
+ class pipe_output(base.NodeTransformer):
1159
+ """Running a series of transformation on the output of the function.
1160
+
1161
+ The decorated function declares the dependency, the body of the function gets executed, and then
1162
+ we run a series of transformations on the result of the function specified by ``pipe_output``.
1163
+
1164
+ If we have nodes **A --> B --> C** in the DAG and decorate **B** with ``pipe_output`` like
1165
+
1166
+ .. code-block:: python
1167
+ :name: Simple @pipe_output example
1168
+
1169
+ @pipe_output(
1170
+ step(B1),
1171
+ step(B2)
1172
+ )
1173
+ def B(...):
1174
+ return ...
1175
+
1176
+ we obtain the new DAG **A --> B.raw --> B1 --> B2 --> B --> C**, where we can think of the **B.raw --> B1 --> B2 --> B** as a "pipe" that takes the raw output of **B**, applies to it
1177
+ **B1**, takes the output of **B1** applies to it **B2** and then gets renamed to **B** to re-connect to the rest of the DAG.
1178
+
1179
+ The rules for chaining nodes are the same as for ``pipe_input``.
1180
+
1181
+ For extra control in case of multiple output nodes, for example after ``extract_field``/ ``extract_columns`` we can also specify the output node that we wish to mutate.
1182
+ The following apply *A* to all fields while *B* only to ``field_1``
1183
+
1184
+ .. code-block:: python
1185
+ :name: Simple @pipe_output example targeting specific nodes
1186
+
1187
+ @extract_columns("col_1", "col_2")
1188
+ def A(...):
1189
+ return ...
1190
+
1191
+ def B(...):
1192
+ return ...
1193
+
1194
+
1195
+ @pipe_output(
1196
+ step(A),
1197
+ step(B).on_output("field_1"),
1198
+ )
1199
+ @extract_fields(
1200
+ {"field_1":int, "field_2":int, "field_3":int}
1201
+ )
1202
+ def foo(a:int)->Dict[str,int]:
1203
+ return {"field_1":1, "field_2":2, "field_3":3}
1204
+
1205
+ We can also do this on the global level (but cannot do on both levels at the same time). The following would apply function *A* and function *B* to only ``field_1`` and ``field_2``
1206
+
1207
+ .. code-block:: python
1208
+ :name: Simple @pipe_output targeting specific nodes local
1209
+
1210
+ @pipe_output(
1211
+ step(A),
1212
+ step(B),
1213
+ on_output = ["field_1","field_2]
1214
+ )
1215
+ @extract_fields(
1216
+ {"field_1":int, "field_2":int, "field_3":int}
1217
+ )
1218
+ def foo(a:int)->Dict[str,int]:
1219
+ return {"field_1":1, "field_2":2, "field_3":3}
1220
+ """
1221
+
1222
+ @classmethod
1223
+ def _validate_single_target_level(cls, target: base.TargetType, transforms: tuple[Applicable]):
1224
+ """We want to make sure that target gets applied on a single level.
1225
+ Either choose for each step individually what it targets or set it on the global level where
1226
+ all steps will target the same node(s).
1227
+ """
1228
+ if target is not None:
1229
+ for transform in transforms:
1230
+ if transform.target is not None:
1231
+ raise SingleTargetError("Cannot have target set on pipe_output and step level.")
1232
+
1233
+ def __init__(
1234
+ self,
1235
+ *transforms: Applicable,
1236
+ namespace: NamespaceType = ...,
1237
+ on_output: base.TargetType = None,
1238
+ collapse=False,
1239
+ _chain=False,
1240
+ ):
1241
+ """Instantiates a ``@pipe_output`` decorator.
1242
+
1243
+ Warning: if there is a global pipe_output target, the individual ``step(...).target`` would only choose
1244
+ from the subset pre-selected from the global pipe_output target. We have disabled this for now to avoid
1245
+ confusion. Leave global pipe_output target empty if you want to choose between all the nodes on the individual step level.
1246
+
1247
+ :param transforms: step transformations to be applied, in order
1248
+ :param namespace: namespace to apply to all nodes in the pipe. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string). Note that you can either use this *or* namespaces inside ``pipe_output()``...
1249
+ :param on_output: setting the target node for all steps in the pipe. Leave empty to select all the output nodes.
1250
+ :param collapse: Whether to collapse this into a single node. This is not currently supported.
1251
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
1252
+ """
1253
+ pipe_output._validate_single_target_level(target=on_output, transforms=transforms)
1254
+
1255
+ if on_output == ...:
1256
+ raise ValueError(
1257
+ "Cannot apply Elipsis(...) to on_output. Use None, single string or list of strings."
1258
+ )
1259
+
1260
+ super(pipe_output, self).__init__(target=on_output)
1261
+ self.transforms = transforms
1262
+ self.collapse = collapse
1263
+ self.chain = _chain
1264
+ self.namespace = namespace
1265
+
1266
+ if self.collapse:
1267
+ raise NotImplementedError(
1268
+ "Collapsing step() functions as one node is not yet implemented for pipe(). Please reach out if you want this feature."
1269
+ )
1270
+
1271
+ if self.chain:
1272
+ raise NotImplementedError("@flow() is not yet supported -- this is ")
1273
+
1274
+ def _filter_individual_target(self, node_):
1275
+ """Resolves target option on the transform level.
1276
+ Adds option that we can decide for each applicable which output node it will target.
1277
+
1278
+ :param node_: The current output node.
1279
+ :return: The set of transforms that target this node
1280
+ """
1281
+ selected_transforms = []
1282
+ for transform in self.transforms:
1283
+ target = transform.target
1284
+ if isinstance(target, str): # user selects single target via string
1285
+ if node_.name == target:
1286
+ selected_transforms.append(transform)
1287
+ elif isinstance(target, Collection): # user inputs a list of targets
1288
+ if node_.name in target:
1289
+ selected_transforms.append(transform)
1290
+ else: # for target=None (default) we include all sink nodes
1291
+ selected_transforms.append(transform)
1292
+
1293
+ return tuple(selected_transforms)
1294
+
1295
+ def transform_node(
1296
+ self, node_: node.Node, config: dict[str, Any], fn: Callable
1297
+ ) -> Collection[node.Node]:
1298
+ """Injects nodes into the graph.
1299
+
1300
+ We create a copy of the original function and rename it to `function_name.raw` to be the
1301
+ initial node. Then we create a node for each step in `post-pipe` and chain them together.
1302
+ The last node is an identity to the previous one with the original name `function_name` to
1303
+ represent an exit point of `pipe_output`.
1304
+ """
1305
+ transforms = self._filter_individual_target(node_)
1306
+ if len(transforms) < 1:
1307
+ # in case no functions in pipeline we short-circuit and return the original node
1308
+ return [node_]
1309
+
1310
+ if self.namespace is None:
1311
+ _namespace = None
1312
+ elif self.namespace is ...:
1313
+ _namespace = node_.name
1314
+ else:
1315
+ _namespace = self.namespace
1316
+
1317
+ # We pick a reserved prefix that ovoids clashes with user defined functions / nodes
1318
+ original_node = node_.copy_with(name=f"{node_.name}.raw")
1319
+
1320
+ is_async = inspect.iscoroutinefunction(fn) # determine if its async
1321
+
1322
+ def __identity(foo: Any) -> Any:
1323
+ return foo
1324
+
1325
+ async def async_function(**kwargs):
1326
+ return await __identity(**kwargs)
1327
+
1328
+ fn_to_use = async_function if is_async else __identity
1329
+
1330
+ transforms = transforms + (step(fn_to_use).named(fn.__name__),)
1331
+ nodes, _ = chain_transforms(
1332
+ target_arg=original_node.name,
1333
+ transforms=transforms,
1334
+ namespace=_namespace, # self.namespace,
1335
+ config=config,
1336
+ fn=fn,
1337
+ )
1338
+
1339
+ # In case config resolves to no pipe functions applied we return the original node and skip pipe
1340
+ if len(nodes) == 1:
1341
+ return [node_]
1342
+
1343
+ last_node = nodes[-1].copy_with(name=f"{node_.name}", typ=nodes[-2].type)
1344
+
1345
+ out = [original_node]
1346
+ out.extend(nodes[:-1])
1347
+ out.append(last_node)
1348
+ return out
1349
+
1350
+ def validate(self, fn: Callable):
1351
+ """Validates the the individual steps work together."""
1352
+ for applicable in self.transforms:
1353
+ applicable.validate(
1354
+ chain_first_param=True, allow_custom_namespace=self.namespace is ...
1355
+ )
1356
+ # TODO -- validate that the types match on the chain (this is de-facto done later)
1357
+
1358
+ def optional_config(self) -> dict[str, Any]:
1359
+ """Declares the optional configuration keys for this decorator.
1360
+ These are configuration keys that can be used by the decorator, but are not required.
1361
+ Along with these we have *defaults*, which we will use to pass to the config.
1362
+
1363
+ :return: The optional configuration keys with defaults. Note that this will return None
1364
+ if we have no idea what they are, which bypasses the configuration filtering we use entirely.
1365
+ This is mainly for the legacy API.
1366
+ """
1367
+ out = {}
1368
+ for applicable in self.transforms:
1369
+ for resolver in applicable.resolvers:
1370
+ out.update(resolver.optional_config)
1371
+ return out
1372
+
1373
+
1374
+ def chain_transforms(
1375
+ target_arg: str,
1376
+ transforms: list[Applicable],
1377
+ namespace: str,
1378
+ config: dict[str, Any],
1379
+ fn: Callable,
1380
+ ):
1381
+ """Chaining nodes together sequentially through the a specified argument.
1382
+
1383
+ :param target_arg: assigning the name of the specified argument of the first node in chain
1384
+ :param transforms: step transformations to be applied, in order
1385
+ :param namespace: namespace to apply to all nodes. This can be "..." (the default), which resolves to the name of the decorated function, None (which means no namespace), or a string (which means that all nodes will be namespaced with that string)
1386
+ :param config: Configuration to use -- this can be specified in the decorator
1387
+ :param fn: initial function that was decorated
1388
+
1389
+ :return: A list of nodes that have been chained together through the specified argument.
1390
+ """
1391
+
1392
+ fn_count = Counter()
1393
+ nodes = []
1394
+ for applicable in transforms:
1395
+ if namespace is not ...:
1396
+ applicable = applicable.namespaced(
1397
+ namespace=namespace
1398
+ ) # we reassign the global namespace
1399
+ if applicable.resolves(config):
1400
+ fn_name = applicable.fn.__name__
1401
+ postfix = "" if fn_count[fn_name] == 0 else f"_{fn_count[fn_name]}"
1402
+ node_name = (
1403
+ applicable.name
1404
+ if applicable.name is not None
1405
+ else f"with{('_' if not fn_name.startswith('_') else '') + fn_name}{postfix}"
1406
+ )
1407
+ raw_node = node.Node.from_fn(
1408
+ applicable.fn,
1409
+ f"with{('_' if not fn_name.startswith('_') else '') + fn_name}{postfix}",
1410
+ )
1411
+ node_namespace = applicable.resolve_namespace(fn.__name__)
1412
+ raw_node = raw_node.copy_with(namespace=node_namespace, name=node_name)
1413
+ # TODO -- validate that the first parameter is the right type/all the same
1414
+ fn_count[fn_name] += 1
1415
+ upstream_inputs, literal_inputs = applicable.bind_function_args(target_arg)
1416
+ nodes.append(
1417
+ raw_node.reassign_inputs(
1418
+ input_names=upstream_inputs,
1419
+ input_values=literal_inputs,
1420
+ )
1421
+ )
1422
+ target_arg = raw_node.name
1423
+ return nodes, target_arg
1424
+
1425
+
1426
+ def apply_to(fn_: Callable | str, **mutating_fn_kwargs: SingleDependency | Any):
1427
+ """Creates an applicable placeholder with potential kwargs that will be applied to a node (or a subcomponent of a node).
1428
+ See documentation for ``mutate`` to see how this is used. It de facto allows a postponed ``step``.
1429
+
1430
+ We pass fn=None here as this will be the function we are decorating and need to delay passing it in. The target
1431
+ function is the one we wish to mutate and we store it for later access.
1432
+
1433
+ :param fn: Function the applicable will be applied to
1434
+ :param mutating_fn_kwargs: Kwargs (**kwargs) to pass to the mutator function. Must be validly called as f(**kwargs), and have a 1:1 mapping of kwargs to parameters.
1435
+ :return: an applicable placeholder with the target function
1436
+ """
1437
+ return Applicable(fn=None, args=(), kwargs=mutating_fn_kwargs, target_fn=fn_, _resolvers=[])
1438
+
1439
+
1440
+ class NotSameModuleError(Exception):
1441
+ """Limit the use of a decorator on functions from the same module.
1442
+
1443
+ Some decorators have the ability to transform also other functions than the one they are decorating (for example mutate).
1444
+ This ensures that all the functions are located within the same module.
1445
+ """
1446
+
1447
+ def __init__(self, fn: Callable, target_fn: Callable):
1448
+ super().__init__(
1449
+ f"The functions have to be in the same module... "
1450
+ f"The target function {target_fn.__name__} is in module {target_fn.__module__} and "
1451
+ f"the mutator function {fn.__name__} is in module {fn.__module__}./n"
1452
+ "Use power user setting to disable this restriction."
1453
+ )
1454
+
1455
+
1456
+ class mutate:
1457
+ """Running a transformation on the outputs of a series of functions.
1458
+
1459
+ This is closely related to ``pipe_output`` as it effectively allows you to run transformations on the output of a node without touching that node.
1460
+ We choose which target functions we wish to mutate by the transformation we are decorating. For now, the target functions, that will be mutated,
1461
+ have to be in the same module (come speak to us if you need this capability over multiple modules).
1462
+
1463
+ We suggest you define them with an prefixed underscore to only have them displayed in the `transform pipeline` of the target node.
1464
+
1465
+ If we wish to apply ``_transform1`` to the output of **A** and **B** and ``_transform2`` only to the output
1466
+ of node **B**, we can do this like
1467
+
1468
+ .. code-block:: python
1469
+ :name: Simple @mutate example
1470
+
1471
+ def A(...):
1472
+ return ...
1473
+
1474
+ def B(...):
1475
+ return ...
1476
+
1477
+ @mutate(A, B)
1478
+ def _transform1(...):
1479
+ return ...
1480
+
1481
+ @mutate(B)
1482
+ def _transform2(...):
1483
+ return ...
1484
+
1485
+ we obtain the new pipe-like subDAGs **A.raw --> _transform1 --> A** and **B.raw --> _transform1 --> _transform2 --> B**,
1486
+ where the behavior is the same as ``pipe_output``.
1487
+
1488
+ While it is generally reasonable to use ``pipe_output``, you should consider ``mutate`` in the following scenarios:
1489
+
1490
+ 1. Loading data and applying pre-cleaning step.
1491
+ 2. Feature engineering via joining, filtering, sorting, etc.
1492
+ 3. Experimenting with different transformations across nodes by selectively turning transformations on / off.
1493
+
1494
+ We assume the first argument of the decorated function to be the output of the function we are targeting.
1495
+ For transformations with multiple arguments you can use key word arguments coupled with ``step`` or ``value``
1496
+ the same as with other ``pipe``-family decorators
1497
+
1498
+ .. code-block:: python
1499
+ :name: Simple @mutate example with multiple arguments
1500
+
1501
+ @mutate(A, B, arg2=step('upstream_node'), arg3=value(some_literal), ...)
1502
+ def _transform1(output_from_target:correct_type, arg2:arg2_type, arg3:arg3_type,...):
1503
+ return ...
1504
+
1505
+ You can also select individual args that will be applied to each target node by adding ``apply_to(...)``
1506
+
1507
+ .. code-block:: python
1508
+ :name: Simple @mutate example with multiple arguments allowing individual actions
1509
+
1510
+ @mutate(
1511
+ apply_to(A, arg2=step('upstream_node_1'), arg3=value(some_literal_1)),
1512
+ apply_to(B, arg2=step('upstream_node_2'), arg3=value(some_literal_2)),
1513
+ )
1514
+ def _transform1(output_from_target:correct_type, arg2:arg2_type, arg3:arg3_type, ...):
1515
+ return ...
1516
+
1517
+ In case of multiple output nodes, for example after ``extract_field`` / ``extract_columns`` we can also specify the output node that we wish to mutate.
1518
+ The following would mutate all columns of *A* individually while in the case of function *B* only ``field_1``
1519
+
1520
+ .. code-block:: python
1521
+ :name: @mutate example targeting specific nodes local
1522
+
1523
+ @extract_columns("col_1", "col_2")
1524
+ def A(...):
1525
+ return ...
1526
+
1527
+ @extract_fields(
1528
+ {"field_1":int, "field_2":int, "field_3":int}
1529
+ )
1530
+ def B(...):
1531
+ return ...
1532
+
1533
+ @mutate(
1534
+ apply_to(A),
1535
+ apply_to(B).on_output("field_1"),
1536
+ )
1537
+
1538
+ def foo(a:int)->Dict[str,int]:
1539
+ return {"field_1":1, "field_2":2, "field_3":3}
1540
+ """
1541
+
1542
+ def __init__(
1543
+ self,
1544
+ *target_functions: Applicable | Callable,
1545
+ collapse: bool = False,
1546
+ _chain: bool = False,
1547
+ **mutating_function_kwargs: SingleDependency | Any,
1548
+ ):
1549
+ """Instantiates a ``mutate`` decorator.
1550
+
1551
+ We assume the first argument of the decorated function to be the output of the function we are targeting.
1552
+
1553
+ :param target_functions: functions we wish to mutate the output of
1554
+ :param collapse: Whether to collapse this into a single node. This is not currently supported.
1555
+ :param _chain: Whether to chain the first parameter. This is the only mode that is supported. Furthermore, this is not externally exposed. ``@flow`` will make use of this.
1556
+ :param `**mutating_function_kwargs`: other kwargs that the decorated function has. Must be validly called as ``f(**kwargs)``, and have a 1-to-1 mapping of kwargs to parameters. This will be applied for all ``target_functions``, unless ``apply_to`` already has the mutator function kwargs, in which case it takes those.
1557
+ """
1558
+ self.collapse = collapse
1559
+ self.chain = _chain
1560
+ # keeping it here once it gets implemented maybe nice to have options
1561
+ if self.collapse:
1562
+ raise NotImplementedError(
1563
+ "Collapsing functions as one node is not yet implemented for mutate(). Please reach out if you want this feature."
1564
+ )
1565
+ if self.chain:
1566
+ raise NotImplementedError("@flow() is not yet supported -- this is ")
1567
+
1568
+ self.remote_applicables = tuple(
1569
+ [apply_to(fn) if isinstance(fn, Callable) else fn for fn in target_functions]
1570
+ )
1571
+ self.mutating_function_kwargs = mutating_function_kwargs
1572
+
1573
+ # Cross module will require some thought so we are restricting mutate to single module for now
1574
+ self.restrict_to_single_module = True
1575
+
1576
+ def validate_same_module(self, mutating_fn: Callable):
1577
+ """Validates target functions are in the same module as the mutator function.
1578
+
1579
+ :param mutating_fn: Function to validate against
1580
+ :return: Nothing, raises exception if not valid.
1581
+ """
1582
+ local_module = mutating_fn.__module__
1583
+ for remote_applicable in self.remote_applicables:
1584
+ if remote_applicable.target_fn.__module__ != local_module:
1585
+ raise NotSameModuleError(fn=mutating_fn, target_fn=remote_applicable.target_fn)
1586
+
1587
+ def _create_step(self, mutating_fn: Callable, remote_applicable_builder: Applicable):
1588
+ """Adds the correct function for the applicable and resolves kwargs"""
1589
+
1590
+ if not remote_applicable_builder.kwargs:
1591
+ remote_applicable_builder.kwargs = self.mutating_function_kwargs
1592
+
1593
+ remote_applicable_builder.fn = mutating_fn
1594
+
1595
+ return remote_applicable_builder
1596
+
1597
+ def __call__(self, mutating_fn: Callable):
1598
+ """Adds to an existing pipe_output or creates a new pipe_output.
1599
+
1600
+ This is a new type of decorator that builds ``pipe_output`` for multiple nodes in the DAG. It does
1601
+ not fit in the current decorator framework since it does not decorate the node function in the DAG
1602
+ but allows us to "remotely decorate" multiple nodes at once, which needs to happen before the
1603
+ NodeTransformLifecycle gets applied / resolved.
1604
+
1605
+ :param mutating_fn: function that will be used in pipe_output to transform target function
1606
+ :return: mutating_fn, to guarantee function works even when Hamilton driver is not used
1607
+ """
1608
+
1609
+ # This function will be excluded from the DAG as a node since we are inserting it manually
1610
+ mutating_fn = hamilton_exclude()(mutating_fn)
1611
+
1612
+ if self.restrict_to_single_module:
1613
+ self.validate_same_module(mutating_fn=mutating_fn)
1614
+
1615
+ # TODO: If @mutate runs once it's good
1616
+ # If you run that again, it might double-up
1617
+ # In the juptyer notebook/cross-module case we'll want to guard against it.
1618
+ for remote_applicable in self.remote_applicables:
1619
+ new_pipe_step = self._create_step(
1620
+ mutating_fn=mutating_fn, remote_applicable_builder=remote_applicable
1621
+ )
1622
+ found_pipe_output = False
1623
+ if hasattr(remote_applicable.target_fn, base.NodeTransformer.get_lifecycle_name()):
1624
+ for decorator in remote_applicable.target_fn.transform:
1625
+ if isinstance(decorator, pipe_output):
1626
+ decorator.transforms = decorator.transforms + (new_pipe_step,)
1627
+ found_pipe_output = True
1628
+
1629
+ if not found_pipe_output:
1630
+ remote_applicable.target_fn = pipe_output(
1631
+ new_pipe_step, collapse=self.collapse, _chain=self.chain
1632
+ )(remote_applicable.target_fn)
1633
+
1634
+ return mutating_fn