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,421 @@
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 logging
19
+ import pprint
20
+ from collections import deque
21
+ from collections.abc import Collection
22
+ from functools import partial
23
+ from typing import Any
24
+
25
+ from hamilton import node
26
+ from hamilton.lifecycle.base import LifecycleAdapterSet
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ """A set of utility functions for managing/traversing DAGs. Note these all operate on nodes.
31
+ We will likely want to genericize them so we're dealing with anything, not just node.Nodes.
32
+ """
33
+
34
+
35
+ def topologically_sort_nodes(nodes: list[node.Node]) -> list[node.Node]:
36
+ """Topologically sorts a list of nodes based on their dependencies.
37
+ Note that we bypass utilizing the preset dependencies/depended_on_by attributes of the node,
38
+ as we may want to use this before these nodes get put in a function graph.
39
+
40
+ Thus we compute our own dependency map...
41
+ Note that this assumes that the nodes are continuous -- if there is a hidden dependency that
42
+ connects them, this has no way of knowing about it.
43
+
44
+ TODO -- use python graphlib when we no longer have to support 3.8.
45
+
46
+ https://docs.python.org/3/library/graphlib.html
47
+
48
+ :param nodes: Nodes to sort
49
+ :return: Nodes in sorted order
50
+ """
51
+ node_name_map = {node_.name: node_ for node_ in nodes}
52
+ depended_on_by_map = {}
53
+ dependency_map = {}
54
+ for node_ in nodes:
55
+ dependency_map[node_.name] = []
56
+ for dep in node_.input_types:
57
+ # if the dependency is not here, we don't want to count it
58
+ # that means it depends on something outside the set of nodes we're sorting
59
+ if dep not in node_name_map:
60
+ continue
61
+ dependency_map[node_.name].append(dep)
62
+ if dep not in depended_on_by_map:
63
+ depended_on_by_map[dep] = []
64
+ depended_on_by_map[dep].append(node_)
65
+
66
+ in_degrees = {node_.name: len(dependency_map.get(node_.name, [])) for node_ in nodes}
67
+ # TODO -- determine what happens if nodes have dependencies that aren't present
68
+ sources = [node_ for node_ in nodes if in_degrees[node_.name] == 0]
69
+ queue = deque()
70
+ for source in sources:
71
+ queue.append(source)
72
+ sorted_nodes = []
73
+ while len(queue) > 0:
74
+ node_ = queue.popleft()
75
+ sorted_nodes.append(node_)
76
+ for next_node in depended_on_by_map.get(node_.name, []):
77
+ if next_node.name in in_degrees:
78
+ in_degrees[next_node.name] -= 1
79
+ if in_degrees[next_node.name] == 0:
80
+ queue.append(next_node)
81
+ return sorted_nodes
82
+
83
+
84
+ def get_node_levels(topologically_sorted_nodes: list[node.Node]) -> dict[str, int]:
85
+ """Gets the levels for a group of topologically sorted nodes.
86
+ This only works if its topologically sorted, of course...
87
+
88
+
89
+ :param topologically_sorted_nodes:
90
+ :return: A dictionary of node name -> level
91
+ """
92
+ node_levels = {}
93
+ node_set = {node_.name for node_ in topologically_sorted_nodes}
94
+ for node_ in topologically_sorted_nodes:
95
+ dependencies_in_set = {n.name for n in node_.dependencies}.intersection(node_set)
96
+ if len(dependencies_in_set) == 0:
97
+ node_levels[node_.name] = 0
98
+ else:
99
+ node_levels[node_.name] = max([node_levels[n] for n in dependencies_in_set]) + 1
100
+ return node_levels
101
+
102
+
103
+ def combine_config_and_inputs(config: dict[str, Any], inputs: dict[str, Any]) -> dict[str, Any]:
104
+ """Validates and combines config and inputs, ensuring that they're mutually disjoint.
105
+ :param config: Config to construct, run the DAG with.
106
+ :param inputs: Inputs to run the DAG on at runtime
107
+ :return: The combined set of inputs to the DAG.
108
+ :raises ValueError: if they are not disjoint
109
+ """
110
+ duplicated_inputs = [key for key in inputs if key in config]
111
+ if len(duplicated_inputs) > 0:
112
+ raise ValueError(
113
+ f"The following inputs are present in both config and inputs. They must be "
114
+ f"mutually disjoint. {duplicated_inputs} "
115
+ )
116
+ return {**config, **inputs}
117
+
118
+
119
+ def create_input_string(kwargs: dict) -> str:
120
+ """This is a utility function to create a string representation of the inputs to a function.
121
+
122
+ This is useful for debugging, as it can be printed out to see what the inputs were.
123
+
124
+ :param kwargs: The inputs to the function that errored.
125
+ :return: The string representation of the inputs, truncated appropriately.
126
+ """
127
+ pp = pprint.PrettyPrinter(width=80)
128
+ inputs = {}
129
+ for k, v in kwargs.items():
130
+ item_repr = repr(v)
131
+ if len(item_repr) > 50:
132
+ item_repr = item_repr[:50] + "..."
133
+ else:
134
+ item_repr = v
135
+ inputs[k] = item_repr
136
+ input_string = pp.pformat(inputs)
137
+ if len(input_string) > 1000:
138
+ input_string = input_string[:1000] + "..."
139
+ return input_string
140
+
141
+
142
+ def create_error_message(kwargs: dict, node_: node.Node, step: str) -> str:
143
+ """Creates an error message for a node that errored."""
144
+ # This code is coupled to how @config resolution works. Ideally it shouldn't be,
145
+ # so when @config resolvers are changed to return Nodes, then fn.__name__ should
146
+ # just work.
147
+ original_func_name = "unknown"
148
+ if node_.originating_functions:
149
+ if hasattr(node_.originating_functions[0], "__original_name__"):
150
+ original_func_name = node_.originating_functions[0].__original_name__
151
+ else:
152
+ original_func_name = node_.originating_functions[0].__name__
153
+ module = (
154
+ node_.originating_functions[0].__module__
155
+ if node_.originating_functions and hasattr(node_.originating_functions[0], "__module__")
156
+ else "unknown_module"
157
+ )
158
+ message = f">{step} {node_.name} [{module}.{original_func_name}()] encountered an error"
159
+ padding = " " * (80 - min(len(message), 79) - 1)
160
+ message += padding + "<"
161
+ input_string = create_input_string(kwargs)
162
+ message += "\n> Node inputs:\n" + input_string
163
+ border = "*" * 80
164
+ message = "\n" + border + "\n" + message + "\n" + border
165
+ return message
166
+
167
+
168
+ def execute_subdag(
169
+ nodes: Collection[node.Node],
170
+ inputs: dict[str, Any],
171
+ adapter: LifecycleAdapterSet = None,
172
+ computed: dict[str, Any] = None,
173
+ overrides: dict[str, Any] = None,
174
+ run_id: str = None,
175
+ task_id: str = None,
176
+ ) -> dict[str, Any]:
177
+ """Base function to execute a subdag. This conducts a depth first traversal of the graph.
178
+
179
+ :param nodes: Nodes to compute
180
+ :param inputs: Inputs, external
181
+ :param adapter: Adapter to use to compute
182
+ :param computed: Already computed nodes
183
+ :param overrides: Overrides to use, will short-circuit computation
184
+ :param run_id: Run ID to use
185
+ :param task_id: Task ID to use -- this is optional for the purpose of the task-based execution...
186
+ :return: The results
187
+ """
188
+ if overrides is None:
189
+ overrides = {}
190
+ if computed is None:
191
+ computed = {}
192
+ nodes_to_compute = {node_.name for node_ in nodes}
193
+
194
+ if adapter is None:
195
+ adapter = LifecycleAdapterSet()
196
+
197
+ def dfs_traverse(
198
+ node_: node.Node, dependency_type: node.DependencyType = node.DependencyType.REQUIRED
199
+ ):
200
+ if node_.name in computed:
201
+ return
202
+ if node_.name in overrides:
203
+ computed[node_.name] = overrides[node_.name]
204
+ return
205
+ for n in node_.dependencies:
206
+ if n.name not in computed:
207
+ _, node_dependency_type = node_.input_types[n.name]
208
+ dfs_traverse(n, node_dependency_type)
209
+
210
+ logger.debug(f"Computing {node_.name}.")
211
+ if node_.user_defined:
212
+ if node_.name not in inputs:
213
+ if dependency_type != node.DependencyType.OPTIONAL:
214
+ raise NotImplementedError(
215
+ f"{node_.name} was expected to be passed in but was not."
216
+ )
217
+ return
218
+ result = inputs[node_.name]
219
+ else:
220
+ kwargs = {} # construct signature
221
+ for dependency in node_.dependencies:
222
+ if dependency.name in computed:
223
+ kwargs[dependency.name] = computed[dependency.name]
224
+
225
+ execute_lifecycle_for_node_partial = partial(
226
+ execute_lifecycle_for_node,
227
+ __node_=node_,
228
+ __adapter=adapter,
229
+ __run_id=run_id,
230
+ __task_id=task_id,
231
+ )
232
+
233
+ if adapter.does_method("do_remote_execute", is_async=False):
234
+ result = adapter.call_lifecycle_method_sync(
235
+ "do_remote_execute",
236
+ node=node_,
237
+ execute_lifecycle_for_node=execute_lifecycle_for_node_partial,
238
+ **kwargs,
239
+ )
240
+ else:
241
+ result = execute_lifecycle_for_node_partial(**kwargs)
242
+
243
+ computed[node_.name] = result
244
+ # > pruning the graph
245
+ # This doesn't narrow it down to the entire space of the graph
246
+ # E.G. if something is not needed by this current execution due to
247
+ # the selection of nodes to run it might not prune everything.
248
+ # to do this we'd need to first determine all nodes on the path, then prune
249
+ # We may also want to use a reference counter for slightly cleaner/more efficient memory management
250
+
251
+ for dep in node_.dependencies:
252
+ if dep.name in computed and dep.name not in nodes_to_compute:
253
+ for downstream_node in dep.depended_on_by:
254
+ # if it isn't computed, and it isn't required, we can't prune
255
+ if (
256
+ downstream_node.name not in computed
257
+ or downstream_node.name in nodes_to_compute
258
+ ):
259
+ break
260
+ # If the result of this node is no longer needed, we can prune it/save the memory
261
+ else:
262
+ del computed[dep.name]
263
+
264
+ for final_var_node in nodes:
265
+ dep_type = node.DependencyType.REQUIRED
266
+ if final_var_node.user_defined:
267
+ # from the top level, we don't know if this UserInput is required. So mark as optional.
268
+ dep_type = node.DependencyType.OPTIONAL
269
+ dfs_traverse(final_var_node, dep_type)
270
+ return computed
271
+
272
+
273
+ # TODO: better function name
274
+ def execute_lifecycle_for_node(
275
+ __node_: node.Node,
276
+ __adapter: LifecycleAdapterSet,
277
+ __run_id: str,
278
+ __task_id: str,
279
+ **__kwargs: dict[str, Any],
280
+ ):
281
+ """Helper function to properly execute node lifecycle.
282
+
283
+ Firstly, we execute the pre-node-execute hooks if supplied adapters have any, then we execute the node function, and lastly, we execute the post-node-execute hooks if present in the adapters.
284
+
285
+ For local runtime gets execute directy. Otherwise, serves as a sandwich function that guarantees the pre_node and post_node lifecycle hooks are executed in the remote environment.
286
+
287
+ :param __node_: Node that is being executed
288
+ :param __adapter: Adapter to use to compute
289
+ :param __run_id: ID of the run, unique in scope of the driver.
290
+ :param __task_id: ID of the task, defaults to None if not in a task setting
291
+ :param ___kwargs: Keyword arguments that are being passed into the node
292
+ """
293
+
294
+ error = None
295
+ result = None
296
+ success = True
297
+ pre_node_execute_errored = False
298
+
299
+ try:
300
+ if __adapter.does_hook("pre_node_execute", is_async=False):
301
+ try:
302
+ __adapter.call_all_lifecycle_hooks_sync(
303
+ "pre_node_execute",
304
+ run_id=__run_id,
305
+ node_=__node_,
306
+ kwargs=__kwargs,
307
+ task_id=__task_id,
308
+ )
309
+ except Exception as e:
310
+ pre_node_execute_errored = True
311
+ raise e
312
+ if __adapter.does_method("do_node_execute", is_async=False):
313
+ result = __adapter.call_lifecycle_method_sync(
314
+ "do_node_execute",
315
+ run_id=__run_id,
316
+ node_=__node_,
317
+ kwargs=__kwargs,
318
+ task_id=__task_id,
319
+ )
320
+ else:
321
+ result = __node_(**__kwargs)
322
+
323
+ return result
324
+
325
+ except Exception as e:
326
+ success = False
327
+ error = e
328
+ step = "[pre-node-execute]" if pre_node_execute_errored else ""
329
+ message = create_error_message(__kwargs, __node_, step)
330
+ logger.exception(message)
331
+ raise
332
+ finally:
333
+ if not pre_node_execute_errored and __adapter.does_hook(
334
+ "post_node_execute", is_async=False
335
+ ):
336
+ try:
337
+ __adapter.call_all_lifecycle_hooks_sync(
338
+ "post_node_execute",
339
+ run_id=__run_id,
340
+ node_=__node_,
341
+ kwargs=__kwargs,
342
+ success=success,
343
+ error=error,
344
+ result=result,
345
+ task_id=__task_id,
346
+ )
347
+ except Exception:
348
+ message = create_error_message(__kwargs, __node_, "[post-node-execute]")
349
+ logger.exception(message)
350
+ raise
351
+
352
+
353
+ def nodes_between(
354
+ end_node: node.Node,
355
+ search_condition: lambda node_: bool,
356
+ ) -> tuple[node.Node | None, list[node.Node]]:
357
+ """Utility function to search backwards from an end node to a start node.
358
+ This returns all nodes for which both of the following conditions are met:
359
+
360
+ 1. It contains a node that matches the start_condition as an ancestor
361
+ 2. It contains a node that matches the end node as a dependent
362
+
363
+ Note that currently it is assumed that only one node will
364
+ match search_condition.
365
+
366
+ This just grabs the search node when it finds it -- the nonlocal is a bit hacky but more fun
367
+ than passing a ton of data back and forth (who the parent is, etc...).
368
+
369
+ :param end_node: Node to trace back from
370
+ :param search_condition: Condition to stop the search for ancestors
371
+ :return: A tuple of [start_node, between], where start_node is None
372
+ if there is no path (and between will be empty).
373
+ """
374
+
375
+ out = set()
376
+ visited = set()
377
+ search_node = None
378
+
379
+ def dfs_traverse(node_: node.Node):
380
+ if search_condition(node_):
381
+ # if we hit the end, we want to include all others in it
382
+ nonlocal search_node
383
+ search_node = node_
384
+ return True
385
+ if node_ in visited:
386
+ # if we've already seen it, we want to include it
387
+ return node_ in out
388
+ # now we mark that we've seen it
389
+ visited.add(node_)
390
+
391
+ any_deps_included = False
392
+ for n in node_.dependencies:
393
+ any_deps_included |= dfs_traverse(n)
394
+ if any_deps_included:
395
+ out.add(node_)
396
+ return any_deps_included
397
+
398
+ for dep in end_node.dependencies:
399
+ dfs_traverse(dep)
400
+
401
+ return search_node, list(out)
402
+
403
+
404
+ def node_is_required_by_anything(node_: node.Node, node_set: set[node.Node]) -> bool:
405
+ """Checks dependencies on this node and determines if at least one requires it.
406
+
407
+ Nodes can be optionally depended upon, i.e. the function parameter has a default value. We want to check that
408
+ of the nodes the depend on this one, at least one of them requires it, i.e. the parameter is not optional.
409
+
410
+ :param node_: node in question
411
+ :param node_set: checks that we traverse only nodes in the provided set.
412
+ :return: True if it is required by any downstream node, false otherwise
413
+ """
414
+ required = False
415
+ for downstream_node in node_.depended_on_by:
416
+ if downstream_node not in node_set:
417
+ continue
418
+ _, dep_type = downstream_node.input_types[node_.name]
419
+ if dep_type == node.DependencyType.REQUIRED:
420
+ return True
421
+ return required