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.
- apache_hamilton-1.90.0.dev0.dist-info/METADATA +407 -0
- apache_hamilton-1.90.0.dev0.dist-info/RECORD +151 -0
- apache_hamilton-1.90.0.dev0.dist-info/WHEEL +4 -0
- apache_hamilton-1.90.0.dev0.dist-info/entry_points.txt +9 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/DISCLAIMER +10 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/LICENSE +228 -0
- apache_hamilton-1.90.0.dev0.dist-info/licenses/NOTICE +5 -0
- hamilton/__init__.py +24 -0
- hamilton/ad_hoc_utils.py +132 -0
- hamilton/async_driver.py +465 -0
- hamilton/base.py +466 -0
- hamilton/caching/__init__.py +16 -0
- hamilton/caching/adapter.py +1475 -0
- hamilton/caching/cache_key.py +70 -0
- hamilton/caching/fingerprinting.py +287 -0
- hamilton/caching/stores/__init__.py +16 -0
- hamilton/caching/stores/base.py +242 -0
- hamilton/caching/stores/file.py +140 -0
- hamilton/caching/stores/memory.py +297 -0
- hamilton/caching/stores/sqlite.py +282 -0
- hamilton/caching/stores/utils.py +40 -0
- hamilton/cli/__init__.py +16 -0
- hamilton/cli/__main__.py +328 -0
- hamilton/cli/commands.py +126 -0
- hamilton/cli/logic.py +338 -0
- hamilton/common/__init__.py +76 -0
- hamilton/contrib/__init__.py +41 -0
- hamilton/data_quality/__init__.py +16 -0
- hamilton/data_quality/base.py +198 -0
- hamilton/data_quality/default_validators.py +560 -0
- hamilton/data_quality/pandera_validators.py +121 -0
- hamilton/dataflows/__init__.py +726 -0
- hamilton/dataflows/template/README.md +25 -0
- hamilton/dataflows/template/__init__.py +54 -0
- hamilton/dataflows/template/author.md +28 -0
- hamilton/dataflows/template/requirements.txt +0 -0
- hamilton/dataflows/template/tags.json +7 -0
- hamilton/dataflows/template/valid_configs.jsonl +1 -0
- hamilton/dev_utils/__init__.py +16 -0
- hamilton/dev_utils/deprecation.py +204 -0
- hamilton/driver.py +2112 -0
- hamilton/execution/__init__.py +16 -0
- hamilton/execution/debugging_utils.py +56 -0
- hamilton/execution/executors.py +502 -0
- hamilton/execution/graph_functions.py +421 -0
- hamilton/execution/grouping.py +430 -0
- hamilton/execution/state.py +539 -0
- hamilton/experimental/__init__.py +27 -0
- hamilton/experimental/databackend.py +61 -0
- hamilton/experimental/decorators/__init__.py +16 -0
- hamilton/experimental/decorators/parameterize_frame.py +233 -0
- hamilton/experimental/h_async.py +29 -0
- hamilton/experimental/h_cache.py +413 -0
- hamilton/experimental/h_dask.py +28 -0
- hamilton/experimental/h_databackends.py +174 -0
- hamilton/experimental/h_ray.py +28 -0
- hamilton/experimental/h_spark.py +32 -0
- hamilton/function_modifiers/README +40 -0
- hamilton/function_modifiers/__init__.py +121 -0
- hamilton/function_modifiers/adapters.py +900 -0
- hamilton/function_modifiers/base.py +859 -0
- hamilton/function_modifiers/configuration.py +310 -0
- hamilton/function_modifiers/delayed.py +202 -0
- hamilton/function_modifiers/dependencies.py +246 -0
- hamilton/function_modifiers/expanders.py +1230 -0
- hamilton/function_modifiers/macros.py +1634 -0
- hamilton/function_modifiers/metadata.py +434 -0
- hamilton/function_modifiers/recursive.py +908 -0
- hamilton/function_modifiers/validation.py +289 -0
- hamilton/function_modifiers_base.py +31 -0
- hamilton/graph.py +1153 -0
- hamilton/graph_types.py +264 -0
- hamilton/graph_utils.py +41 -0
- hamilton/htypes.py +450 -0
- hamilton/io/__init__.py +32 -0
- hamilton/io/data_adapters.py +216 -0
- hamilton/io/default_data_loaders.py +224 -0
- hamilton/io/materialization.py +500 -0
- hamilton/io/utils.py +158 -0
- hamilton/lifecycle/__init__.py +67 -0
- hamilton/lifecycle/api.py +833 -0
- hamilton/lifecycle/base.py +1130 -0
- hamilton/lifecycle/default.py +802 -0
- hamilton/log_setup.py +47 -0
- hamilton/models.py +92 -0
- hamilton/node.py +449 -0
- hamilton/plugins/README.md +48 -0
- hamilton/plugins/__init__.py +16 -0
- hamilton/plugins/dask_extensions.py +47 -0
- hamilton/plugins/dlt_extensions.py +161 -0
- hamilton/plugins/geopandas_extensions.py +49 -0
- hamilton/plugins/h_dask.py +331 -0
- hamilton/plugins/h_ddog.py +522 -0
- hamilton/plugins/h_diskcache.py +163 -0
- hamilton/plugins/h_experiments/__init__.py +22 -0
- hamilton/plugins/h_experiments/__main__.py +62 -0
- hamilton/plugins/h_experiments/cache.py +39 -0
- hamilton/plugins/h_experiments/data_model.py +68 -0
- hamilton/plugins/h_experiments/hook.py +219 -0
- hamilton/plugins/h_experiments/server.py +375 -0
- hamilton/plugins/h_kedro.py +152 -0
- hamilton/plugins/h_logging.py +454 -0
- hamilton/plugins/h_mcp/__init__.py +28 -0
- hamilton/plugins/h_mcp/__main__.py +33 -0
- hamilton/plugins/h_mcp/_helpers.py +129 -0
- hamilton/plugins/h_mcp/_templates.py +417 -0
- hamilton/plugins/h_mcp/server.py +328 -0
- hamilton/plugins/h_mlflow.py +335 -0
- hamilton/plugins/h_narwhals.py +134 -0
- hamilton/plugins/h_openlineage.py +400 -0
- hamilton/plugins/h_opentelemetry.py +167 -0
- hamilton/plugins/h_pandas.py +257 -0
- hamilton/plugins/h_pandera.py +117 -0
- hamilton/plugins/h_polars.py +304 -0
- hamilton/plugins/h_polars_lazyframe.py +282 -0
- hamilton/plugins/h_pyarrow.py +56 -0
- hamilton/plugins/h_pydantic.py +127 -0
- hamilton/plugins/h_ray.py +242 -0
- hamilton/plugins/h_rich.py +142 -0
- hamilton/plugins/h_schema.py +493 -0
- hamilton/plugins/h_slack.py +100 -0
- hamilton/plugins/h_spark.py +1380 -0
- hamilton/plugins/h_threadpool.py +125 -0
- hamilton/plugins/h_tqdm.py +122 -0
- hamilton/plugins/h_vaex.py +129 -0
- hamilton/plugins/huggingface_extensions.py +236 -0
- hamilton/plugins/ibis_extensions.py +93 -0
- hamilton/plugins/jupyter_magic.py +622 -0
- hamilton/plugins/kedro_extensions.py +117 -0
- hamilton/plugins/lightgbm_extensions.py +99 -0
- hamilton/plugins/matplotlib_extensions.py +108 -0
- hamilton/plugins/mlflow_extensions.py +216 -0
- hamilton/plugins/numpy_extensions.py +105 -0
- hamilton/plugins/pandas_extensions.py +1763 -0
- hamilton/plugins/plotly_extensions.py +150 -0
- hamilton/plugins/polars_extensions.py +71 -0
- hamilton/plugins/polars_implementations.py +25 -0
- hamilton/plugins/polars_lazyframe_extensions.py +302 -0
- hamilton/plugins/polars_post_1_0_0_extensions.py +877 -0
- hamilton/plugins/polars_pre_1_0_0_extension.py +836 -0
- hamilton/plugins/pydantic_extensions.py +98 -0
- hamilton/plugins/pyspark_pandas_extensions.py +47 -0
- hamilton/plugins/sklearn_plot_extensions.py +129 -0
- hamilton/plugins/spark_extensions.py +105 -0
- hamilton/plugins/vaex_extensions.py +51 -0
- hamilton/plugins/xgboost_extensions.py +91 -0
- hamilton/plugins/yaml_extensions.py +89 -0
- hamilton/registry.py +254 -0
- hamilton/settings.py +18 -0
- hamilton/telemetry.py +50 -0
- hamilton/version.py +18 -0
hamilton/graph.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
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
|
+
"""
|
|
19
|
+
This module should not have any real business logic.
|
|
20
|
+
|
|
21
|
+
It should only house the graph & things required to create and traverse one.
|
|
22
|
+
|
|
23
|
+
Note: one should largely consider the code in this module to be "private".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import html
|
|
27
|
+
import inspect
|
|
28
|
+
import logging
|
|
29
|
+
import os.path
|
|
30
|
+
import pathlib
|
|
31
|
+
import uuid
|
|
32
|
+
from collections.abc import Callable, Collection
|
|
33
|
+
from enum import Enum
|
|
34
|
+
from types import ModuleType
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
import hamilton.lifecycle.base as lifecycle_base
|
|
38
|
+
from hamilton import graph_types, node
|
|
39
|
+
from hamilton.execution import graph_functions
|
|
40
|
+
from hamilton.function_modifiers import base as fm_base
|
|
41
|
+
from hamilton.function_modifiers.metadata import schema
|
|
42
|
+
from hamilton.graph_utils import find_functions
|
|
43
|
+
from hamilton.htypes import get_type_as_string, types_match
|
|
44
|
+
from hamilton.node import Node
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VisualizationNodeModifiers(Enum):
|
|
50
|
+
"""Enum of all possible node modifiers for visualization."""
|
|
51
|
+
|
|
52
|
+
IS_OUTPUT = 1
|
|
53
|
+
IS_PATH = 2
|
|
54
|
+
IS_USER_INPUT = 3
|
|
55
|
+
IS_OVERRIDE = 4
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_dependency(
|
|
59
|
+
func_node: node.Node,
|
|
60
|
+
func_name: str,
|
|
61
|
+
nodes: dict[str, node.Node],
|
|
62
|
+
param_name: str,
|
|
63
|
+
param_type: type,
|
|
64
|
+
adapter: lifecycle_base.LifecycleAdapterSet,
|
|
65
|
+
):
|
|
66
|
+
"""Adds dependencies to the node objects.
|
|
67
|
+
|
|
68
|
+
This will add user defined inputs to the dictionary of nodes in the graph.
|
|
69
|
+
|
|
70
|
+
:param func_node: the node we're pulling dependencies from.
|
|
71
|
+
:param func_name: the name of the function we're inspecting.
|
|
72
|
+
:param nodes: nodes representing the graph. This function mutates this object and underlying objects.
|
|
73
|
+
:param param_name: the parameter name we're looking for/adding as a dependency.
|
|
74
|
+
:param param_type: the type of the parameter.
|
|
75
|
+
:param adapter: The adapter that adapts our node type checking based on the context.
|
|
76
|
+
"""
|
|
77
|
+
adapter_checks_types = adapter.does_method("do_check_edge_types_match", is_async=False)
|
|
78
|
+
if param_name in nodes:
|
|
79
|
+
# validate types match
|
|
80
|
+
required_node: Node = nodes[param_name]
|
|
81
|
+
types_do_match = types_match(param_type, required_node.type)
|
|
82
|
+
types_do_match |= adapter_checks_types and adapter.call_lifecycle_method_sync(
|
|
83
|
+
"do_check_edge_types_match",
|
|
84
|
+
type_from=param_type,
|
|
85
|
+
type_to=required_node.type,
|
|
86
|
+
)
|
|
87
|
+
if not types_do_match and required_node.user_defined:
|
|
88
|
+
# check the case that two input type expectations are compatible, e.g. is one a subset of the other
|
|
89
|
+
# this could be the case when one is a union and the other is a subset of that union
|
|
90
|
+
# which is fine for inputs. If they are not compatible, we raise an error.
|
|
91
|
+
types_are_compatible = types_match(required_node.type, param_type)
|
|
92
|
+
types_are_compatible |= adapter_checks_types and adapter.call_lifecycle_method_sync(
|
|
93
|
+
"do_check_edge_types_match",
|
|
94
|
+
type_from=param_type,
|
|
95
|
+
type_to=required_node.type,
|
|
96
|
+
)
|
|
97
|
+
if not types_are_compatible:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Error: Two or more functions are requesting {param_name}, but have incompatible types. "
|
|
100
|
+
f"{func_name} requires {param_name} to be {param_type}, but found another function(s) "
|
|
101
|
+
f"{[f.__name__ for f in required_node.originating_functions]} that minimally require {param_name} "
|
|
102
|
+
f"as {required_node.type}. Please fix this by ensuring that all functions requesting {param_name} "
|
|
103
|
+
f"have compatible types. If you believe they are equivalent, please reach out to the developers. "
|
|
104
|
+
f"Note that, if you have types that are equivalent for your purposes, you can create a "
|
|
105
|
+
f"graph adapter that checks the types against each other in a more lenient manner."
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
# replace the current type with this "tighter" type.
|
|
109
|
+
required_node.set_type(param_type)
|
|
110
|
+
# add to the originating functions
|
|
111
|
+
for og_func in func_node.originating_functions:
|
|
112
|
+
required_node.add_originating_function(og_func)
|
|
113
|
+
elif not types_do_match:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Error: {func_name} is expecting {param_name}:{param_type}, but found "
|
|
116
|
+
f"{param_name}:{required_node.type}. \nHamilton does not consider these types to be "
|
|
117
|
+
f"equivalent. If you believe they are equivalent, please reach out to the developers. "
|
|
118
|
+
f"Note that, if you have types that are equivalent for your purposes, you can create a "
|
|
119
|
+
f"graph adapter that checks the types against each other in a more lenient manner."
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
# this is a user defined var, i.e. an input to the graph.
|
|
123
|
+
required_node = node.Node(
|
|
124
|
+
param_name,
|
|
125
|
+
param_type,
|
|
126
|
+
node_source=node.NodeType.EXTERNAL,
|
|
127
|
+
originating_functions=func_node.originating_functions,
|
|
128
|
+
)
|
|
129
|
+
nodes[param_name] = required_node
|
|
130
|
+
# add edges
|
|
131
|
+
func_node.dependencies.append(required_node)
|
|
132
|
+
required_node.depended_on_by.append(func_node)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def update_dependencies(
|
|
136
|
+
nodes: dict[str, node.Node],
|
|
137
|
+
adapter: lifecycle_base.LifecycleAdapterSet,
|
|
138
|
+
reset_dependencies: bool = True,
|
|
139
|
+
):
|
|
140
|
+
"""Adds dependencies to a dictionary of nodes. If in_place is False,
|
|
141
|
+
it will deepcopy the dict + nodes and return that. Otherwise it will
|
|
142
|
+
mutate + return the passed-in dict + nodes.
|
|
143
|
+
|
|
144
|
+
Note: this will add in "input" nodes if they are not already present.
|
|
145
|
+
|
|
146
|
+
:param in_place: Whether or not to modify in-place, or copy/return
|
|
147
|
+
:param nodes: Nodes that form the DAG we're updating
|
|
148
|
+
:param adapter: Adapter to use for type checking
|
|
149
|
+
:param reset_dependencies: Whether or not to reset the dependencies. If they are not set this is
|
|
150
|
+
unnecessary, and we can save yet another pass. Note that `reset` will perform an in-place
|
|
151
|
+
operation.
|
|
152
|
+
:return: The updated nodes
|
|
153
|
+
"""
|
|
154
|
+
# copy without the dependencies to avoid duplicates
|
|
155
|
+
if reset_dependencies:
|
|
156
|
+
nodes = {k: v.copy(include_refs=False) for k, v in nodes.items()}
|
|
157
|
+
for node_name, n in list(nodes.items()):
|
|
158
|
+
for param_name, (param_type, _) in n.input_types.items():
|
|
159
|
+
add_dependency(n, node_name, nodes, param_name, param_type, adapter)
|
|
160
|
+
return nodes
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_function_graph(
|
|
164
|
+
*modules: ModuleType,
|
|
165
|
+
config: dict[str, Any],
|
|
166
|
+
adapter: lifecycle_base.LifecycleAdapterSet = None,
|
|
167
|
+
fg: Optional["FunctionGraph"] = None,
|
|
168
|
+
allow_module_overrides: bool = False,
|
|
169
|
+
) -> dict[str, node.Node]:
|
|
170
|
+
"""Creates a graph of all available functions & their dependencies.
|
|
171
|
+
:param modules: A set of modules over which one wants to compute the function graph
|
|
172
|
+
:param config: Dictionary that we will inspect to get values from in building the function graph.
|
|
173
|
+
:param adapter: The adapter that adapts our node type checking based on the context.
|
|
174
|
+
:return: list of nodes in the graph.
|
|
175
|
+
If it needs to be more complicated, we'll return an actual networkx graph and get all the rest of the logic for free
|
|
176
|
+
"""
|
|
177
|
+
if adapter is None:
|
|
178
|
+
adapter = (
|
|
179
|
+
lifecycle_base.LifecycleAdapterSet()
|
|
180
|
+
) # empty one -- not provided/necessary, we can run without it
|
|
181
|
+
if fg is None:
|
|
182
|
+
nodes = {} # name -> Node
|
|
183
|
+
else:
|
|
184
|
+
nodes = fg.nodes
|
|
185
|
+
functions = sum([find_functions(module) for module in modules], [])
|
|
186
|
+
|
|
187
|
+
# create non-input nodes -- easier to just create this in one loop
|
|
188
|
+
for _func_name, f in functions:
|
|
189
|
+
for n in fm_base.resolve_nodes(f, config):
|
|
190
|
+
if n.name in config:
|
|
191
|
+
continue # This makes sure we overwrite things if they're in the config...
|
|
192
|
+
if n.name in nodes and not allow_module_overrides:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Cannot define function {n.name} more than once."
|
|
195
|
+
f" Already defined by function {f}"
|
|
196
|
+
f" In case you want to override the previous functions check out .allow_module_overrides() at: https://hamilton.apache.org/reference/drivers/Driver/"
|
|
197
|
+
)
|
|
198
|
+
nodes[n.name] = n
|
|
199
|
+
# add dependencies -- now that all nodes except input nodes, we just run through edges & validate graph.
|
|
200
|
+
nodes = update_dependencies(nodes, adapter, reset_dependencies=False) # no dependencies
|
|
201
|
+
# present yet
|
|
202
|
+
for key in config.keys():
|
|
203
|
+
if key not in nodes:
|
|
204
|
+
nodes[key] = node.Node(key, Any, node_source=node.NodeType.EXTERNAL)
|
|
205
|
+
return nodes
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_keyword_args_only(func: Callable) -> bool:
|
|
209
|
+
"""Checks if a function only takes keyword arguments."""
|
|
210
|
+
sig = inspect.signature(func)
|
|
211
|
+
for param in sig.parameters.values():
|
|
212
|
+
if param.default is inspect.Parameter.empty and param.kind not in [
|
|
213
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
214
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
215
|
+
]:
|
|
216
|
+
return False
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def create_graphviz_graph(
|
|
221
|
+
nodes: set[node.Node],
|
|
222
|
+
comment: str,
|
|
223
|
+
graphviz_kwargs: dict,
|
|
224
|
+
node_modifiers: dict[str, set[VisualizationNodeModifiers]],
|
|
225
|
+
strictly_display_only_nodes_passed_in: bool,
|
|
226
|
+
show_legend: bool = True,
|
|
227
|
+
orient: str = "LR",
|
|
228
|
+
hide_inputs: bool = False,
|
|
229
|
+
deduplicate_inputs: bool = False,
|
|
230
|
+
display_fields: bool = True,
|
|
231
|
+
custom_style_function: Callable = None,
|
|
232
|
+
config: dict = None,
|
|
233
|
+
) -> "graphviz.Digraph": # noqa: F821
|
|
234
|
+
"""Helper function to create a graphviz graph.
|
|
235
|
+
|
|
236
|
+
:param nodes: The set of computational nodes
|
|
237
|
+
:param comment: The comment to have on the graph.
|
|
238
|
+
:param graphviz_kwargs: kwargs to pass to create the graph.
|
|
239
|
+
e.g. dict(graph_attr={'ratio': '1'}) will set the aspect ratio to be equal of the produced image.
|
|
240
|
+
:param node_modifiers: A dictionary of node names to dictionaries of node attributes to modify.
|
|
241
|
+
:param strictly_display_only_nodes_passed_in: If True, only display the nodes passed in. Else defaults to displaying
|
|
242
|
+
also what nodes a node depends on (i.e. all nodes that feed into it).
|
|
243
|
+
:param show_legend: If True, add a legend to the visualization based on the DAG's nodes.
|
|
244
|
+
:param orient: `LR` stands for "left to right". Accepted values are TB, LR, BT, RL.
|
|
245
|
+
`orient` will be overwridden by the value of `graphviz_kwargs['graph_attr']['rankdir']`
|
|
246
|
+
see (https://graphviz.org/docs/attr-types/rankdir/)
|
|
247
|
+
:param hide_inputs: If True, no input nodes are displayed.
|
|
248
|
+
:param deduplicate_inputs: If True, remove duplicate input nodes.
|
|
249
|
+
Can improve readability depending on the specifics of the DAG.
|
|
250
|
+
:param custom_style_function: A function that takes in node values and returns a dictionary of styles to apply to it.
|
|
251
|
+
:param config: The Driver config. This value is passed by the caller, e.g., driver.display_all_functions(),
|
|
252
|
+
and shouldn't be passed explicitly. Otherwise, it may not match the `nodes` argument leading to an
|
|
253
|
+
incorrect visualization.
|
|
254
|
+
:return: a graphviz.Digraph; use this to render/save a graph representation.
|
|
255
|
+
"""
|
|
256
|
+
PATH_COLOR = "red"
|
|
257
|
+
MAX_STRING_LENGTH = 80
|
|
258
|
+
|
|
259
|
+
import graphviz
|
|
260
|
+
|
|
261
|
+
if custom_style_function is not None:
|
|
262
|
+
if not _check_keyword_args_only(custom_style_function):
|
|
263
|
+
raise ValueError(
|
|
264
|
+
"custom_style_function must only take keyword arguments"
|
|
265
|
+
" `node` and `node_class`. E.g. Signature should resemble:\n"
|
|
266
|
+
"def custom_style(*, # <--- key part is this * \n"
|
|
267
|
+
" node: graph_types.HamiltonNode, \n"
|
|
268
|
+
" node_class: str\n"
|
|
269
|
+
") -> Tuple[dict, Optional[str], Optional[str]]:"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if config is None:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"Received None for kwarg `config`. Make sure to pass a dictionary that matches the Driver config.\n"
|
|
275
|
+
"If you're seeing this error, you're likely using a non-public API."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _get_node_label(
|
|
279
|
+
n: node.Node,
|
|
280
|
+
name: str | None = None,
|
|
281
|
+
type_string: str | None = None,
|
|
282
|
+
) -> str:
|
|
283
|
+
"""Get a graphviz HTML-like node label. It uses the DAG node
|
|
284
|
+
name and type but values can be overridden. Overriding is currently
|
|
285
|
+
used for materializers since `type_` is stored in n.tags.
|
|
286
|
+
|
|
287
|
+
If a node has a 'display_name' tag, it will be used as the label
|
|
288
|
+
instead of the node name. This allows human-readable names in
|
|
289
|
+
visualizations while keeping Python-valid identifiers as node names.
|
|
290
|
+
See: https://github.com/apache/hamilton/issues/1413
|
|
291
|
+
|
|
292
|
+
ref: https://graphviz.org/doc/info/shapes.html#html
|
|
293
|
+
"""
|
|
294
|
+
# Determine display name: explicit name param > display_name tag > node.name
|
|
295
|
+
if name is not None:
|
|
296
|
+
display_name = name
|
|
297
|
+
elif n.tags.get("display_name"):
|
|
298
|
+
display_name = n.tags["display_name"]
|
|
299
|
+
# Handle case where display_name is a list (use first element)
|
|
300
|
+
if isinstance(display_name, list):
|
|
301
|
+
display_name = display_name[0] if display_name else n.name
|
|
302
|
+
else:
|
|
303
|
+
display_name = n.name
|
|
304
|
+
|
|
305
|
+
if type_string is None:
|
|
306
|
+
type_string = get_type_as_string(n.type) or ""
|
|
307
|
+
|
|
308
|
+
# We need to ensure that name and type string are HTML-escaped
|
|
309
|
+
# strings to avoid syntax errors. This is particularly important
|
|
310
|
+
# because config *values* and display_name tags are passed through this function
|
|
311
|
+
# see issue: https://github.com/apache/hamilton/issues/1200
|
|
312
|
+
# see graphviz ref: https://graphviz.org/doc/info/shapes.html#html
|
|
313
|
+
if len(type_string) > MAX_STRING_LENGTH:
|
|
314
|
+
type_string = type_string[:MAX_STRING_LENGTH] + "[...]"
|
|
315
|
+
|
|
316
|
+
escaped_display_name = html.escape(display_name, quote=True)
|
|
317
|
+
escaped_type_string = html.escape(type_string, quote=True)
|
|
318
|
+
return f"<<b>{escaped_display_name}</b><br /><br /><i>{escaped_type_string}</i>>"
|
|
319
|
+
|
|
320
|
+
def _get_input_label(input_nodes: frozenset[node.Node]) -> str:
|
|
321
|
+
"""Get a graphviz HTML-like node label formatted as a table.
|
|
322
|
+
Each row is a different input node with one column containing
|
|
323
|
+
the name (or display_name if present) and the other the type.
|
|
324
|
+
ref: https://graphviz.org/doc/info/shapes.html#html
|
|
325
|
+
"""
|
|
326
|
+
rows = []
|
|
327
|
+
for dep in input_nodes:
|
|
328
|
+
# Use display_name tag if present, otherwise use node name
|
|
329
|
+
display_name = dep.tags.get("display_name", dep.name)
|
|
330
|
+
# Handle case where display_name is a list (use first element)
|
|
331
|
+
if isinstance(display_name, list):
|
|
332
|
+
display_name = display_name[0] if display_name else dep.name
|
|
333
|
+
type_string = get_type_as_string(dep.type) or ""
|
|
334
|
+
# HTML escape for security
|
|
335
|
+
escaped_display_name = html.escape(display_name, quote=True)
|
|
336
|
+
escaped_type_string = html.escape(type_string, quote=True)
|
|
337
|
+
rows.append(f"<tr><td>{escaped_display_name}</td><td>{escaped_type_string}</td></tr>")
|
|
338
|
+
return f'<<table border="0">{"".join(rows)}</table>>'
|
|
339
|
+
|
|
340
|
+
def _get_node_type(n: node.Node) -> str:
|
|
341
|
+
"""Get the node type of a DAG node.
|
|
342
|
+
|
|
343
|
+
Input: is external, doesn't originate from a function, functions depend on it
|
|
344
|
+
Config: is external, doesn't originate from a function, no function depedends on it
|
|
345
|
+
Function: others
|
|
346
|
+
"""
|
|
347
|
+
if n._node_source == node.NodeType.EXTERNAL and n._depended_on_by:
|
|
348
|
+
return "input"
|
|
349
|
+
elif (
|
|
350
|
+
n._node_source == node.NodeType.EXTERNAL
|
|
351
|
+
and n._originating_functions is None
|
|
352
|
+
and not n._depended_on_by
|
|
353
|
+
):
|
|
354
|
+
return "config"
|
|
355
|
+
else:
|
|
356
|
+
return "function"
|
|
357
|
+
|
|
358
|
+
def _get_node_style(node_type: str) -> dict[str, str]:
|
|
359
|
+
"""Get the style of a node type.
|
|
360
|
+
Graphviz needs values to be strings.
|
|
361
|
+
"""
|
|
362
|
+
fontname = "Helvetica"
|
|
363
|
+
|
|
364
|
+
if node_type == "config":
|
|
365
|
+
node_style = dict(
|
|
366
|
+
shape="note",
|
|
367
|
+
style="filled",
|
|
368
|
+
fontname=fontname,
|
|
369
|
+
)
|
|
370
|
+
elif node_type == "input":
|
|
371
|
+
node_style = dict(
|
|
372
|
+
shape="rectangle",
|
|
373
|
+
margin="0.15",
|
|
374
|
+
style="filled,dashed",
|
|
375
|
+
fontname=fontname,
|
|
376
|
+
)
|
|
377
|
+
elif node_type == "materializer":
|
|
378
|
+
node_style = dict(
|
|
379
|
+
shape="cylinder",
|
|
380
|
+
style="filled",
|
|
381
|
+
margin="0.15,0.1",
|
|
382
|
+
fontname=fontname,
|
|
383
|
+
)
|
|
384
|
+
else: # this is a function or else
|
|
385
|
+
node_style = dict(
|
|
386
|
+
shape="rectangle",
|
|
387
|
+
margin="0.15",
|
|
388
|
+
style="rounded,filled",
|
|
389
|
+
fillcolor="#b4d8e4",
|
|
390
|
+
fontname=fontname,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return node_style
|
|
394
|
+
|
|
395
|
+
def _get_function_modifier_style(modifier: str) -> dict[str, str]:
|
|
396
|
+
"""Get the style of a modifier. The dictionary returned
|
|
397
|
+
is used to overwrite values of the base node style.
|
|
398
|
+
Graphviz needs values to be strings.
|
|
399
|
+
"""
|
|
400
|
+
if modifier == "output":
|
|
401
|
+
modifier_style = dict(fillcolor="#FFC857")
|
|
402
|
+
elif modifier == "collect":
|
|
403
|
+
modifier_style = dict(peripheries="2", color="#EA5556")
|
|
404
|
+
elif modifier == "expand":
|
|
405
|
+
modifier_style = dict(peripheries="2", color="#56E39F")
|
|
406
|
+
elif modifier == "override":
|
|
407
|
+
modifier_style = dict(style="filled,diagonals")
|
|
408
|
+
elif modifier == "materializer":
|
|
409
|
+
modifier_style = dict(shape="cylinder")
|
|
410
|
+
elif modifier == "field":
|
|
411
|
+
modifier_style = dict(fillcolor="#c8dae0", fontname="Courier")
|
|
412
|
+
elif modifier == "cluster":
|
|
413
|
+
modifier_style = dict(
|
|
414
|
+
fillcolor="#ffffff", color="#649fb3", style="rounded,filled,dashed"
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
modifier_style = dict()
|
|
418
|
+
|
|
419
|
+
return modifier_style
|
|
420
|
+
|
|
421
|
+
def _get_edge_style(from_type: str, to_type: str) -> dict:
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
Graphviz needs values to be strings.
|
|
425
|
+
"""
|
|
426
|
+
edge_style = dict()
|
|
427
|
+
if from_type == "expand":
|
|
428
|
+
edge_style.update(
|
|
429
|
+
dir="both",
|
|
430
|
+
arrowhead="crow",
|
|
431
|
+
arrowtail="none",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if to_type == "collect":
|
|
435
|
+
edge_style.update(dir="both", arrowtail="crow")
|
|
436
|
+
|
|
437
|
+
return edge_style
|
|
438
|
+
|
|
439
|
+
def _get_legend(
|
|
440
|
+
node_types: set[str], extra_legend_nodes: dict[tuple[str, str], dict[str, str]]
|
|
441
|
+
):
|
|
442
|
+
"""Create a visualization legend as a graphviz subgraph. The legend includes the
|
|
443
|
+
node types and modifiers presente in the visualization.
|
|
444
|
+
"""
|
|
445
|
+
legend_subgraph = graphviz.Digraph(
|
|
446
|
+
name="cluster__legend", # needs to start with `cluster` for graphviz layout
|
|
447
|
+
graph_attr=dict(
|
|
448
|
+
label="Legend",
|
|
449
|
+
rank="same", # makes the legend perpendicular to the main DAG
|
|
450
|
+
fontname="helvetica",
|
|
451
|
+
fillcolor="#ffffff",
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
sorted_types = [
|
|
456
|
+
"config",
|
|
457
|
+
"input",
|
|
458
|
+
"function",
|
|
459
|
+
"cluster",
|
|
460
|
+
"field",
|
|
461
|
+
"output",
|
|
462
|
+
"materializer",
|
|
463
|
+
"override",
|
|
464
|
+
"expand",
|
|
465
|
+
"collect",
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
for node_type in sorted(node_types, key=lambda t: sorted_types.index(t)):
|
|
469
|
+
node_style = _get_node_style(node_type)
|
|
470
|
+
modifier_style = _get_function_modifier_style(node_type)
|
|
471
|
+
node_style.update(**modifier_style)
|
|
472
|
+
legend_subgraph.node(name=node_type, **node_style)
|
|
473
|
+
|
|
474
|
+
for (base_class, node_name), legend_style in extra_legend_nodes.items():
|
|
475
|
+
base_style = _get_node_style(base_class)
|
|
476
|
+
base_style.update(**legend_style)
|
|
477
|
+
legend_subgraph.node(name=node_name, **base_style)
|
|
478
|
+
|
|
479
|
+
return legend_subgraph
|
|
480
|
+
|
|
481
|
+
# handle default values in nested dict
|
|
482
|
+
digraph_attr = dict(
|
|
483
|
+
comment=comment,
|
|
484
|
+
graph_attr=dict(
|
|
485
|
+
rankdir=orient,
|
|
486
|
+
ranksep="0.4",
|
|
487
|
+
compound="true",
|
|
488
|
+
concentrate="true",
|
|
489
|
+
style="filled",
|
|
490
|
+
# bgcolor="transparent",
|
|
491
|
+
),
|
|
492
|
+
node_attr=dict(
|
|
493
|
+
fillcolor="#ffffff",
|
|
494
|
+
),
|
|
495
|
+
edge_attr=dict(
|
|
496
|
+
# color="#ffffff",
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
# we need to update the graph_attr dict instead of overwriting it
|
|
500
|
+
# so that means we need to handle nested dicts, e.g. graph_attr.
|
|
501
|
+
for g_key, g_value in graphviz_kwargs.items():
|
|
502
|
+
if isinstance(g_value, dict):
|
|
503
|
+
digraph_attr[g_key].update(**g_value)
|
|
504
|
+
else:
|
|
505
|
+
digraph_attr[g_key] = g_value
|
|
506
|
+
|
|
507
|
+
digraph = graphviz.Digraph(**digraph_attr)
|
|
508
|
+
extra_legend_nodes = {}
|
|
509
|
+
|
|
510
|
+
for config_key, config_value in config.items():
|
|
511
|
+
label = _get_node_label(n=None, name=config_key, type_string=str(config_value))
|
|
512
|
+
style = _get_node_style("config")
|
|
513
|
+
digraph.node(config_key, label=label, **style)
|
|
514
|
+
|
|
515
|
+
# create nodes
|
|
516
|
+
seen_node_types = set()
|
|
517
|
+
for n in nodes:
|
|
518
|
+
label = _get_node_label(n)
|
|
519
|
+
node_type = _get_node_type(n)
|
|
520
|
+
if node_type == "input":
|
|
521
|
+
seen_node_types.add(node_type)
|
|
522
|
+
continue
|
|
523
|
+
# config nodes are handled separately;
|
|
524
|
+
# only Driver.display_all_functions() passes config via the `nodes` arg
|
|
525
|
+
elif node_type == "config":
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
# append config key to node label
|
|
529
|
+
config_key = n.tags.get("hamilton.config", None)
|
|
530
|
+
if config_key:
|
|
531
|
+
seen_node_types.add("config")
|
|
532
|
+
label = _get_node_label(n, name=f"{n.name}: {config_key}")
|
|
533
|
+
|
|
534
|
+
node_style = _get_node_style(node_type)
|
|
535
|
+
|
|
536
|
+
# prefer having the conditions explicit for now since they rely on
|
|
537
|
+
# heterogeneous VisualizationNodeModifiers and node.Node.node_role.
|
|
538
|
+
# Otherwise, it's difficult to manage seen nodes and the legend.
|
|
539
|
+
if n.node_role == node.NodeType.EXPAND:
|
|
540
|
+
modifier_style = _get_function_modifier_style("expand")
|
|
541
|
+
node_style.update(**modifier_style)
|
|
542
|
+
seen_node_types.add("expand")
|
|
543
|
+
|
|
544
|
+
if n.node_role == node.NodeType.COLLECT:
|
|
545
|
+
modifier_style = _get_function_modifier_style("collect")
|
|
546
|
+
node_style.update(**modifier_style)
|
|
547
|
+
seen_node_types.add("collect")
|
|
548
|
+
|
|
549
|
+
if n.tags.get("hamilton.data_saver"):
|
|
550
|
+
materializer_type = n.tags["hamilton.data_saver.classname"]
|
|
551
|
+
label = _get_node_label(n, type_string=materializer_type)
|
|
552
|
+
modifier_style = _get_function_modifier_style("materializer")
|
|
553
|
+
node_style.update(**modifier_style)
|
|
554
|
+
seen_node_types.add("materializer")
|
|
555
|
+
|
|
556
|
+
# we use tags to identify what is a data loader
|
|
557
|
+
# but we have two ways that we need to capture, hence the clauses.
|
|
558
|
+
if n.tags.get("hamilton.data_loader") and (
|
|
559
|
+
"load_data." in n.name or "loader" == n.tags.get("hamilton.data_loader.source")
|
|
560
|
+
):
|
|
561
|
+
materializer_type = n.tags["hamilton.data_loader.classname"]
|
|
562
|
+
label = _get_node_label(n, type_string=materializer_type)
|
|
563
|
+
modifier_style = _get_function_modifier_style("materializer")
|
|
564
|
+
node_style.update(**modifier_style)
|
|
565
|
+
seen_node_types.add("materializer")
|
|
566
|
+
|
|
567
|
+
# apply custom styles before node modifiers
|
|
568
|
+
seen_node_type = None
|
|
569
|
+
if custom_style_function:
|
|
570
|
+
custom_style, base_type, legend_name = custom_style_function(
|
|
571
|
+
node=graph_types.HamiltonNode.from_node(n), node_class=node_type
|
|
572
|
+
)
|
|
573
|
+
if legend_name:
|
|
574
|
+
extra_legend_nodes[(base_type, legend_name)] = custom_style
|
|
575
|
+
seen_node_type = legend_name
|
|
576
|
+
node_style.update(**custom_style)
|
|
577
|
+
|
|
578
|
+
if node_modifiers.get(n.name):
|
|
579
|
+
modifiers = node_modifiers[n.name]
|
|
580
|
+
if VisualizationNodeModifiers.IS_OUTPUT in modifiers:
|
|
581
|
+
modifier_style = _get_function_modifier_style("output")
|
|
582
|
+
node_style.update(**modifier_style)
|
|
583
|
+
seen_node_types.add("output")
|
|
584
|
+
|
|
585
|
+
if VisualizationNodeModifiers.IS_OVERRIDE in modifiers:
|
|
586
|
+
modifier_style = _get_function_modifier_style("override")
|
|
587
|
+
node_style.update(**modifier_style)
|
|
588
|
+
seen_node_types.add("override")
|
|
589
|
+
|
|
590
|
+
if VisualizationNodeModifiers.IS_PATH in modifiers:
|
|
591
|
+
# use PATH_COLOR only if no color applied, edges provide enough clarity
|
|
592
|
+
# currently, only EXPAND and COLLECT use the `color` attribue
|
|
593
|
+
node_style["color"] = node_style.get("color", PATH_COLOR)
|
|
594
|
+
|
|
595
|
+
digraph.node(n.name, label=label, **node_style)
|
|
596
|
+
|
|
597
|
+
# only do field-level visualization if there's a schema specified and we want to display it
|
|
598
|
+
if n.tags.get(schema.INTERNAL_SCHEMA_OUTPUT_KEY) and display_fields:
|
|
599
|
+
# When a node has attached schema data -> add a cluster with a node for each field
|
|
600
|
+
seen_node_types.add("cluster")
|
|
601
|
+
seen_node_types.add("field")
|
|
602
|
+
|
|
603
|
+
def _create_equal_length_cols(schema_tag: str) -> list[str]:
|
|
604
|
+
cols = schema_tag.split(",")
|
|
605
|
+
for i in range(len(cols)):
|
|
606
|
+
|
|
607
|
+
def _insert_space_after_colon(col: str) -> str:
|
|
608
|
+
colon_index = col.find(":")
|
|
609
|
+
if colon_index != -1 and col[colon_index + 1] != " ":
|
|
610
|
+
return col[: colon_index + 1] + " " + col[colon_index + 1 :]
|
|
611
|
+
return col
|
|
612
|
+
|
|
613
|
+
cols[i] = _insert_space_after_colon(cols[i].strip())
|
|
614
|
+
max_length = max([len(col) for col in cols])
|
|
615
|
+
return [col.ljust(max_length) for col in cols]
|
|
616
|
+
|
|
617
|
+
cluster_node_style = node_style.copy()
|
|
618
|
+
cluster_node_style.update(
|
|
619
|
+
{
|
|
620
|
+
"color": "#649fb3",
|
|
621
|
+
"style": "rounded,filled,dashed",
|
|
622
|
+
"fillcolor": "#ffffff",
|
|
623
|
+
"margin": "10",
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
field_node_style = node_style.copy()
|
|
627
|
+
field_node_style.update(
|
|
628
|
+
{
|
|
629
|
+
"fillcolor": "#c8dae0",
|
|
630
|
+
"fontname": "Courier",
|
|
631
|
+
"fontsize": "10",
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
with digraph.subgraph(name="cluster_" + n.name) as c:
|
|
635
|
+
c.attr(**cluster_node_style)
|
|
636
|
+
cols = _create_equal_length_cols(n.tags.get(schema.INTERNAL_SCHEMA_OUTPUT_KEY))
|
|
637
|
+
for i in range(len(cols)):
|
|
638
|
+
# We may want to change the name. We have a different name than label,
|
|
639
|
+
# as it crowds out the global namespace
|
|
640
|
+
# For now, however, this should be unique
|
|
641
|
+
c.node(n.name + ":" + cols[i], **field_node_style, label=cols[i])
|
|
642
|
+
c.node(n.name)
|
|
643
|
+
|
|
644
|
+
if seen_node_type is None:
|
|
645
|
+
seen_node_types.add(node_type)
|
|
646
|
+
|
|
647
|
+
# create edges
|
|
648
|
+
input_sets = dict()
|
|
649
|
+
for n in nodes:
|
|
650
|
+
to_type = "collect" if n.node_role == node.NodeType.COLLECT else ""
|
|
651
|
+
to_modifiers = node_modifiers.get(n.name, set())
|
|
652
|
+
|
|
653
|
+
input_nodes = set()
|
|
654
|
+
for d in n.dependencies:
|
|
655
|
+
if strictly_display_only_nodes_passed_in and d not in nodes:
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
dependency_type = _get_node_type(d)
|
|
659
|
+
# input nodes and edges are gathered instead of drawn
|
|
660
|
+
# they are drawn later, see below
|
|
661
|
+
if dependency_type == "input":
|
|
662
|
+
input_nodes.add(d)
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
from_type = "expand" if d.node_role == node.NodeType.EXPAND else ""
|
|
666
|
+
dependency_modifiers = node_modifiers.get(d.name, set())
|
|
667
|
+
edge_style = _get_edge_style(from_type, to_type)
|
|
668
|
+
if (
|
|
669
|
+
VisualizationNodeModifiers.IS_PATH in dependency_modifiers
|
|
670
|
+
and VisualizationNodeModifiers.IS_PATH in to_modifiers
|
|
671
|
+
):
|
|
672
|
+
edge_style["color"] = PATH_COLOR
|
|
673
|
+
|
|
674
|
+
digraph.edge(d.name, n.name, **edge_style)
|
|
675
|
+
|
|
676
|
+
# skip input node creation
|
|
677
|
+
if hide_inputs:
|
|
678
|
+
continue
|
|
679
|
+
|
|
680
|
+
# draw input nodes if at least 1 exist
|
|
681
|
+
if len(input_nodes) > 0:
|
|
682
|
+
input_node_name = f"_{n.name}_inputs"
|
|
683
|
+
|
|
684
|
+
# following block is for input node deduplication
|
|
685
|
+
input_nodes = frozenset(input_nodes)
|
|
686
|
+
if input_sets.get(input_nodes):
|
|
687
|
+
existing_input_name = input_sets[input_nodes]
|
|
688
|
+
digraph.edge(existing_input_name, n.name)
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
# allow duplicate input nodes by never storing keys
|
|
692
|
+
if deduplicate_inputs:
|
|
693
|
+
input_sets[input_nodes] = input_node_name
|
|
694
|
+
|
|
695
|
+
# create input node
|
|
696
|
+
node_label = _get_input_label(input_nodes)
|
|
697
|
+
node_style = _get_node_style("input")
|
|
698
|
+
digraph.node(name=input_node_name, label=node_label, **node_style)
|
|
699
|
+
# create edge for input node
|
|
700
|
+
digraph.edge(input_node_name, n.name)
|
|
701
|
+
|
|
702
|
+
if show_legend:
|
|
703
|
+
digraph.subgraph(_get_legend(seen_node_types, extra_legend_nodes))
|
|
704
|
+
|
|
705
|
+
return digraph
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def create_networkx_graph(
|
|
709
|
+
nodes: set[node.Node], user_nodes: set[node.Node], name: str
|
|
710
|
+
) -> "networkx.DiGraph": # noqa: F821
|
|
711
|
+
"""Helper function to create a networkx graph.
|
|
712
|
+
|
|
713
|
+
:param nodes: The set of computational nodes
|
|
714
|
+
:param user_nodes: The set of nodes that the user is providing inputs for.
|
|
715
|
+
:param name: The name to have on the graph.
|
|
716
|
+
:return: a graphviz.Digraph; use this to render/save a graph representation.
|
|
717
|
+
"""
|
|
718
|
+
import networkx
|
|
719
|
+
|
|
720
|
+
digraph = networkx.DiGraph(name=name)
|
|
721
|
+
for n in nodes:
|
|
722
|
+
digraph.add_node(n.name, label=n.name)
|
|
723
|
+
for n in user_nodes:
|
|
724
|
+
digraph.add_node(n.name, label=f"UD: {n.name}")
|
|
725
|
+
|
|
726
|
+
for n in list(nodes) + list(user_nodes):
|
|
727
|
+
for d in n.dependencies:
|
|
728
|
+
digraph.add_edge(d.name, n.name)
|
|
729
|
+
return digraph
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
class FunctionGraph:
|
|
733
|
+
"""Note: this object should be considered private until stated otherwise.
|
|
734
|
+
|
|
735
|
+
That is, you should not try to build off of it directly without chatting to us first.
|
|
736
|
+
"""
|
|
737
|
+
|
|
738
|
+
def __init__(
|
|
739
|
+
self,
|
|
740
|
+
nodes: dict[str, Node],
|
|
741
|
+
config: dict[str, Any],
|
|
742
|
+
adapter: lifecycle_base.LifecycleAdapterSet = None,
|
|
743
|
+
):
|
|
744
|
+
"""Initializes a function graph from specified nodes. See note on `from_modules` if you
|
|
745
|
+
start getting an error here because you use an internal API.
|
|
746
|
+
|
|
747
|
+
:param nodes: Nodes, taken from the output of create_function_graph.
|
|
748
|
+
:param config: this is configuration and/or initial data.
|
|
749
|
+
:param adapter: Group of adapters, to use for node resolution.
|
|
750
|
+
"""
|
|
751
|
+
if adapter is None:
|
|
752
|
+
adapter = lifecycle_base.LifecycleAdapterSet()
|
|
753
|
+
|
|
754
|
+
self._config = config
|
|
755
|
+
self.nodes = nodes
|
|
756
|
+
self.adapter = adapter
|
|
757
|
+
|
|
758
|
+
@staticmethod
|
|
759
|
+
def from_modules(
|
|
760
|
+
*modules: ModuleType,
|
|
761
|
+
config: dict[str, Any],
|
|
762
|
+
adapter: lifecycle_base.LifecycleAdapterSet = None,
|
|
763
|
+
allow_module_overrides: bool = False,
|
|
764
|
+
):
|
|
765
|
+
"""Initializes a function graph from the specified modules. Note that this was the old
|
|
766
|
+
way we constructed FunctionGraph -- this is not a public-facing API, so we replaced it
|
|
767
|
+
with a constructor that takes in nodes directly. If you hacked in something using
|
|
768
|
+
`FunctionGraph`, then you should be able to replace it with `FunctionGraph.from_modules`
|
|
769
|
+
and it will work.
|
|
770
|
+
|
|
771
|
+
:param modules: Modules to crawl, resolve to nodes
|
|
772
|
+
:param config: Config to use for node resolution
|
|
773
|
+
:param adapter: All adapters, to use for node resolution
|
|
774
|
+
:return: a function graph.
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
nodes = create_function_graph(
|
|
778
|
+
*modules, config=config, adapter=adapter, allow_module_overrides=allow_module_overrides
|
|
779
|
+
)
|
|
780
|
+
return FunctionGraph(nodes, config, adapter)
|
|
781
|
+
|
|
782
|
+
def with_nodes(self, nodes: dict[str, Node]) -> "FunctionGraph":
|
|
783
|
+
"""Creates a new function graph with the additional specified nodes.
|
|
784
|
+
Note that if there is a duplication in the node definitions,
|
|
785
|
+
it will error out.
|
|
786
|
+
|
|
787
|
+
:param nodes: Nodes to add to the FunctionGraph
|
|
788
|
+
:return: a new function graph.
|
|
789
|
+
"""
|
|
790
|
+
# first check if there are any duplicates
|
|
791
|
+
duplicates = set(self.nodes.keys()).intersection(set(nodes.keys()))
|
|
792
|
+
if duplicates:
|
|
793
|
+
raise ValueError(f"Duplicate node names found: {duplicates}")
|
|
794
|
+
new_node_dict = update_dependencies({**self.nodes, **nodes}, self.adapter)
|
|
795
|
+
return FunctionGraph(new_node_dict, self.config, self.adapter)
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def config(self):
|
|
799
|
+
return self._config
|
|
800
|
+
|
|
801
|
+
@property
|
|
802
|
+
def decorator_counter(self) -> dict[str, int]:
|
|
803
|
+
return fm_base.DECORATOR_COUNTER
|
|
804
|
+
|
|
805
|
+
def get_nodes(self) -> list[node.Node]:
|
|
806
|
+
return list(self.nodes.values())
|
|
807
|
+
|
|
808
|
+
def display_all(
|
|
809
|
+
self,
|
|
810
|
+
output_file_path: str = "test-output/graph-all.gv",
|
|
811
|
+
render_kwargs: dict = None,
|
|
812
|
+
graphviz_kwargs: dict = None,
|
|
813
|
+
show_legend: bool = True,
|
|
814
|
+
orient: str = "LR",
|
|
815
|
+
hide_inputs: bool = False,
|
|
816
|
+
deduplicate_inputs: bool = False,
|
|
817
|
+
display_fields: bool = True,
|
|
818
|
+
custom_style_function: Callable = None,
|
|
819
|
+
keep_dot: bool = False,
|
|
820
|
+
) -> Optional["graphviz.Digraph"]: # noqa F821
|
|
821
|
+
"""Displays & saves a dot file of the entire DAG structure constructed.
|
|
822
|
+
|
|
823
|
+
:param output_file_path: the place to save the files.
|
|
824
|
+
:param render_kwargs: a dictionary of values we'll pass to graphviz render function. Defaults to viewing.
|
|
825
|
+
If you do not want to view the file, pass in `{'view':False}`.
|
|
826
|
+
:param graphviz_kwargs: kwargs to be passed to the graphviz graph object to configure it.
|
|
827
|
+
e.g. dict(graph_attr={'ratio': '1'}) will set the aspect ratio to be equal of the produced image.
|
|
828
|
+
:param show_legend: If True, add a legend to the visualization based on the DAG's nodes.
|
|
829
|
+
:param orient: `LR` stands for "left to right". Accepted values are TB, LR, BT, RL.
|
|
830
|
+
`orient` will be overwridden by the value of `graphviz_kwargs['graph_attr']['rankdir']`
|
|
831
|
+
see (https://graphviz.org/docs/attr-types/rankdir/)
|
|
832
|
+
:param hide_inputs: If True, no input nodes are displayed.
|
|
833
|
+
:param deduplicate_inputs: If True, remove duplicate input nodes.
|
|
834
|
+
Can improve readability depending on the specifics of the DAG.
|
|
835
|
+
:param display_fields: If True, display fields in the graph if node has attached schema metadata
|
|
836
|
+
:param custom_style_function: Optional. Custom style function.
|
|
837
|
+
:param keep_dot: If true, produce a DOT file (ref: https://graphviz.org/doc/info/lang.html)
|
|
838
|
+
:return: the graphviz graph object if it was created. None if not.
|
|
839
|
+
"""
|
|
840
|
+
all_nodes = set()
|
|
841
|
+
node_modifiers = {}
|
|
842
|
+
for n in self.nodes.values():
|
|
843
|
+
if n.user_defined:
|
|
844
|
+
node_modifiers[n.name] = {VisualizationNodeModifiers.IS_USER_INPUT}
|
|
845
|
+
all_nodes.add(n)
|
|
846
|
+
if render_kwargs is None:
|
|
847
|
+
render_kwargs = {}
|
|
848
|
+
if graphviz_kwargs is None:
|
|
849
|
+
graphviz_kwargs = {}
|
|
850
|
+
return self.display(
|
|
851
|
+
all_nodes,
|
|
852
|
+
output_file_path=output_file_path,
|
|
853
|
+
render_kwargs=render_kwargs,
|
|
854
|
+
graphviz_kwargs=graphviz_kwargs,
|
|
855
|
+
node_modifiers=node_modifiers,
|
|
856
|
+
show_legend=show_legend,
|
|
857
|
+
orient=orient,
|
|
858
|
+
hide_inputs=hide_inputs,
|
|
859
|
+
deduplicate_inputs=deduplicate_inputs,
|
|
860
|
+
display_fields=display_fields,
|
|
861
|
+
custom_style_function=custom_style_function,
|
|
862
|
+
config=self._config,
|
|
863
|
+
keep_dot=keep_dot,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def has_cycles(self, nodes: set[node.Node], user_nodes: set[node.Node]) -> bool:
|
|
867
|
+
"""Checks that the graph created does not contain cycles.
|
|
868
|
+
|
|
869
|
+
:param nodes: the set of nodes that need to be computed.
|
|
870
|
+
:param user_nodes: the set of inputs that the user provided.
|
|
871
|
+
:return: bool. True if cycles detected. False if not.
|
|
872
|
+
"""
|
|
873
|
+
cycles = self.get_cycles(nodes, user_nodes)
|
|
874
|
+
return True if cycles else False
|
|
875
|
+
|
|
876
|
+
def get_cycles(self, nodes: set[node.Node], user_nodes: set[node.Node]) -> list[list[str]]:
|
|
877
|
+
"""Returns cycles found in the graph.
|
|
878
|
+
|
|
879
|
+
:param nodes: the set of nodes that need to be computed.
|
|
880
|
+
:param user_nodes: the set of inputs that the user provided.
|
|
881
|
+
:return: list of cycles, which is a list of node names.
|
|
882
|
+
"""
|
|
883
|
+
try:
|
|
884
|
+
import networkx
|
|
885
|
+
except ModuleNotFoundError:
|
|
886
|
+
logger.exception(
|
|
887
|
+
" networkx is required for detecting cycles in the function graph. Install it with:"
|
|
888
|
+
'\n\n pip install "sf-hamilton[visualization]" or pip install networkx \n\n'
|
|
889
|
+
)
|
|
890
|
+
return []
|
|
891
|
+
digraph = create_networkx_graph(nodes, user_nodes, "Dependency Graph")
|
|
892
|
+
cycles = list(networkx.simple_cycles(digraph))
|
|
893
|
+
return cycles
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def display(
|
|
897
|
+
nodes: set[node.Node],
|
|
898
|
+
output_file_path: str | None = None,
|
|
899
|
+
render_kwargs: dict = None,
|
|
900
|
+
graphviz_kwargs: dict = None,
|
|
901
|
+
node_modifiers: dict[str, set[VisualizationNodeModifiers]] = None,
|
|
902
|
+
strictly_display_only_passed_in_nodes: bool = False,
|
|
903
|
+
show_legend: bool = True,
|
|
904
|
+
orient: str = "LR",
|
|
905
|
+
hide_inputs: bool = False,
|
|
906
|
+
deduplicate_inputs: bool = False,
|
|
907
|
+
display_fields: bool = True,
|
|
908
|
+
custom_style_function: Callable = None,
|
|
909
|
+
config: dict = None,
|
|
910
|
+
keep_dot: bool = False,
|
|
911
|
+
) -> Optional["graphviz.Digraph"]: # noqa F821
|
|
912
|
+
"""Function to display the graph represented by the passed in nodes.
|
|
913
|
+
|
|
914
|
+
The output file format is determined through the following steps, each one overwriting the previous one:
|
|
915
|
+
1. if `output_file_path` has no file extension, a PNG file is generated (e.g., `/path/to/file -> /path/to/file.png`)
|
|
916
|
+
2. if `output_file_path` has a file extension, graphviz will use the specified format (e.g., `/path/to/file.svg -> /path/to/file.svg`))
|
|
917
|
+
3. if a format value is specified for `render_kwargs={"format": "pdf"}`, it overrides any other inputs (e.g., /path/to/file.svg -> /path/to/file.pdf)
|
|
918
|
+
|
|
919
|
+
:param nodes: the set of nodes that need to be computed.
|
|
920
|
+
:param output_file_path: the path where we want to store the `dot` file + png picture. Pass in None to not save.
|
|
921
|
+
:param render_kwargs: kwargs to be passed to the render function to visualize.
|
|
922
|
+
:param graphviz_kwargs: kwargs to be passed to the graphviz graph object to configure it.
|
|
923
|
+
e.g. dict(graph_attr={'ratio': '1'}) will set the aspect ratio to be equal of the produced image.
|
|
924
|
+
:param node_modifiers: a dictionary of node names to a dictionary of attributes to modify.
|
|
925
|
+
e.g. {'node_name': {NodeModifiers.IS_USER_INPUT}} will set the node named 'node_name' to be a user input.
|
|
926
|
+
:param strictly_display_only_passed_in_nodes: if True, only display the nodes passed in. Else defaults to
|
|
927
|
+
displaying also what nodes a node depends on (i.e. all nodes that feed into it).
|
|
928
|
+
:param show_legend: If True, add a legend to the visualization based on the DAG's nodes.
|
|
929
|
+
:param orient: `LR` stands for "left to right". Accepted values are TB, LR, BT, RL.
|
|
930
|
+
`orient` will be overridden by the value of `graphviz_kwargs['graph_attr']['rankdir']`
|
|
931
|
+
see (https://graphviz.org/docs/attr-types/rankdir/)
|
|
932
|
+
:param hide_inputs: If True, no input nodes are displayed.
|
|
933
|
+
:param deduplicate_inputs: If True, remove duplicate input nodes.
|
|
934
|
+
Can improve readability depending on the specifics of the DAG.
|
|
935
|
+
:param display_fields: If True, display fields in the graph if node has attached
|
|
936
|
+
schema metadata
|
|
937
|
+
:param custom_style_function: Optional. Custom style function.
|
|
938
|
+
:param config: The Driver config. This value is passed by the caller, e.g., driver.display_all_functions(),
|
|
939
|
+
and shouldn't be passed explicitly. Otherwise, it may not match the `nodes` argument leading to an
|
|
940
|
+
incorrect visualization.
|
|
941
|
+
:return: the graphviz graph object if it was created. None if not.
|
|
942
|
+
"""
|
|
943
|
+
# Check to see if optional dependencies have been installed.
|
|
944
|
+
try:
|
|
945
|
+
import graphviz # noqa: F401
|
|
946
|
+
except ModuleNotFoundError:
|
|
947
|
+
logger.exception(
|
|
948
|
+
" graphviz is required for visualizing the function graph. Install it with:"
|
|
949
|
+
'\n\n pip install "sf-hamilton[visualization]" or pip install graphviz \n\n'
|
|
950
|
+
)
|
|
951
|
+
return
|
|
952
|
+
if graphviz_kwargs is None:
|
|
953
|
+
graphviz_kwargs = {}
|
|
954
|
+
if node_modifiers is None:
|
|
955
|
+
node_modifiers = {}
|
|
956
|
+
dot = create_graphviz_graph(
|
|
957
|
+
nodes,
|
|
958
|
+
"Dependency Graph",
|
|
959
|
+
graphviz_kwargs,
|
|
960
|
+
node_modifiers,
|
|
961
|
+
strictly_display_only_passed_in_nodes,
|
|
962
|
+
show_legend,
|
|
963
|
+
orient,
|
|
964
|
+
hide_inputs,
|
|
965
|
+
deduplicate_inputs,
|
|
966
|
+
display_fields=display_fields,
|
|
967
|
+
custom_style_function=custom_style_function,
|
|
968
|
+
config=config,
|
|
969
|
+
)
|
|
970
|
+
kwargs = {"format": "png"} # default format = png
|
|
971
|
+
if output_file_path: # infer format from path
|
|
972
|
+
output_file_path, suffix = os.path.splitext(output_file_path)
|
|
973
|
+
if suffix != "":
|
|
974
|
+
inferred_format = suffix.partition(".")[-1]
|
|
975
|
+
kwargs.update(format=inferred_format)
|
|
976
|
+
if render_kwargs and isinstance(render_kwargs, dict): # accept explicit format
|
|
977
|
+
kwargs.update(render_kwargs)
|
|
978
|
+
# .render()` and `.pipe()` have quirks to handle separately
|
|
979
|
+
# - `render()` accepts a `view` kwarg
|
|
980
|
+
# - `render()` will append it's kwarg `format` to the filename
|
|
981
|
+
if output_file_path:
|
|
982
|
+
if keep_dot:
|
|
983
|
+
kwargs["view"] = kwargs.get("view", False)
|
|
984
|
+
dot.render(output_file_path, **kwargs)
|
|
985
|
+
else:
|
|
986
|
+
kwargs.pop("view", None)
|
|
987
|
+
output_file_path = f"{output_file_path}.{kwargs['format']}"
|
|
988
|
+
pathlib.Path(output_file_path).write_bytes(dot.pipe(**kwargs))
|
|
989
|
+
return dot
|
|
990
|
+
|
|
991
|
+
def get_impacted_nodes(self, var_changes: list[str]) -> set[node.Node]:
|
|
992
|
+
"""DEPRECATED - use `get_downstream_nodes` instead."""
|
|
993
|
+
logger.warning(
|
|
994
|
+
"FunctionGraph.get_impacted_nodes is deprecated. "
|
|
995
|
+
"Use `get_downstream_nodes` instead. This function will be removed"
|
|
996
|
+
"in a future release."
|
|
997
|
+
)
|
|
998
|
+
return self.get_downstream_nodes(var_changes)
|
|
999
|
+
|
|
1000
|
+
def get_downstream_nodes(self, var_changes: list[str]) -> set[node.Node]:
|
|
1001
|
+
"""Given our function graph, and a list of nodes that are changed,
|
|
1002
|
+
returns the subgraph that they will impact.
|
|
1003
|
+
|
|
1004
|
+
:param var_changes: the list of nodes that will change.
|
|
1005
|
+
:return: A set of all changed nodes.
|
|
1006
|
+
"""
|
|
1007
|
+
nodes, user_nodes = self.directional_dfs_traverse(
|
|
1008
|
+
lambda n: n.depended_on_by, starting_nodes=var_changes
|
|
1009
|
+
)
|
|
1010
|
+
return nodes
|
|
1011
|
+
|
|
1012
|
+
def get_upstream_nodes(
|
|
1013
|
+
self,
|
|
1014
|
+
final_vars: list[str],
|
|
1015
|
+
runtime_inputs: dict[str, Any] = None,
|
|
1016
|
+
runtime_overrides: dict[str, Any] = None,
|
|
1017
|
+
) -> tuple[set[node.Node], set[node.Node]]:
|
|
1018
|
+
"""Given our function graph, and a list of desired output variables, returns the subgraph
|
|
1019
|
+
required to compute them.
|
|
1020
|
+
|
|
1021
|
+
:param final_vars: the list of node names we want.
|
|
1022
|
+
:param runtime_inputs: runtime inputs to the DAG -- if not provided we will assume we're running at compile-time. Everything
|
|
1023
|
+
would then be required (even though it might be marked as optional), as we want to crawl
|
|
1024
|
+
the whole DAG. If we're at runtime, we want to just use the nodes that are provided,
|
|
1025
|
+
and not count the optional ones that are not.
|
|
1026
|
+
:param runtime_overrides: runtime overrides to the DAG -- if not provided we will assume
|
|
1027
|
+
we're running at compile-time.
|
|
1028
|
+
:return: a tuple of sets: - set of all nodes. - subset of nodes that human input is required for.
|
|
1029
|
+
"""
|
|
1030
|
+
|
|
1031
|
+
def next_nodes_function(n: node.Node) -> list[node.Node]:
|
|
1032
|
+
deps = []
|
|
1033
|
+
if runtime_overrides is not None and n.name in runtime_overrides:
|
|
1034
|
+
return deps
|
|
1035
|
+
if runtime_inputs is None:
|
|
1036
|
+
return n.dependencies
|
|
1037
|
+
for dep in n.dependencies:
|
|
1038
|
+
# If inputs is None, we want to assume its required, as it is a compile-time
|
|
1039
|
+
# dependency
|
|
1040
|
+
if (
|
|
1041
|
+
dep.user_defined
|
|
1042
|
+
and dep.name not in runtime_inputs
|
|
1043
|
+
and dep.name not in self.config
|
|
1044
|
+
):
|
|
1045
|
+
_, dependency_type = n.input_types[dep.name]
|
|
1046
|
+
if dependency_type == node.DependencyType.OPTIONAL:
|
|
1047
|
+
continue
|
|
1048
|
+
deps.append(dep)
|
|
1049
|
+
return deps
|
|
1050
|
+
|
|
1051
|
+
return self.directional_dfs_traverse(
|
|
1052
|
+
next_nodes_function, starting_nodes=final_vars, runtime_inputs=runtime_inputs
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
def nodes_between(self, start: str, end: str) -> set[node.Node]:
|
|
1056
|
+
"""Given our function graph, and a list of desired output variables, returns the subgraph
|
|
1057
|
+
required to compute them. Note that this returns an empty set if no path exists.
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
:param start: the start of the subDAG we're looking for (inputs to it)
|
|
1061
|
+
:param end: the end of the subDAG we're looking for
|
|
1062
|
+
:return: The set of all nodes between start and end inclusive
|
|
1063
|
+
"""
|
|
1064
|
+
|
|
1065
|
+
end_node = self.nodes[end]
|
|
1066
|
+
start_node, between = graph_functions.nodes_between(
|
|
1067
|
+
self.nodes[end], lambda node_: node_.name == start
|
|
1068
|
+
)
|
|
1069
|
+
path_exists = start_node is not None
|
|
1070
|
+
if not path_exists:
|
|
1071
|
+
return set()
|
|
1072
|
+
return set(([start_node] if start_node is not None else []) + between + [end_node])
|
|
1073
|
+
|
|
1074
|
+
def directional_dfs_traverse(
|
|
1075
|
+
self,
|
|
1076
|
+
next_nodes_fn: Callable[[node.Node], Collection[node.Node]],
|
|
1077
|
+
starting_nodes: list[str],
|
|
1078
|
+
runtime_inputs: dict[str, Any] = None,
|
|
1079
|
+
):
|
|
1080
|
+
"""Traverses the DAG directionally using a DFS.
|
|
1081
|
+
|
|
1082
|
+
:param next_nodes_fn: Function to give the next set of nodes
|
|
1083
|
+
:param starting_nodes: Which nodes to start at.
|
|
1084
|
+
:param runtime_inputs: runtime inputs to the DAG. This is here to allow for inputs to be also outputs.
|
|
1085
|
+
:return: a tuple of sets:
|
|
1086
|
+
- set of all nodes.
|
|
1087
|
+
- subset of nodes that human input is required for.
|
|
1088
|
+
"""
|
|
1089
|
+
if runtime_inputs is None:
|
|
1090
|
+
runtime_inputs = {}
|
|
1091
|
+
nodes = set()
|
|
1092
|
+
user_nodes = set()
|
|
1093
|
+
|
|
1094
|
+
def dfs_traverse_iterative(start_node: node.Node):
|
|
1095
|
+
"""Iterative DFS to avoid recursion depth limits with large DAGs."""
|
|
1096
|
+
stack = [start_node]
|
|
1097
|
+
nodes.add(start_node)
|
|
1098
|
+
while stack:
|
|
1099
|
+
n = stack.pop()
|
|
1100
|
+
if n.user_defined:
|
|
1101
|
+
user_nodes.add(n)
|
|
1102
|
+
# reversed() preserves the same traversal order as the recursive version
|
|
1103
|
+
for next_n in reversed(next_nodes_fn(n)):
|
|
1104
|
+
if next_n not in nodes:
|
|
1105
|
+
nodes.add(next_n)
|
|
1106
|
+
stack.append(next_n)
|
|
1107
|
+
|
|
1108
|
+
missing_vars = []
|
|
1109
|
+
for var in starting_nodes:
|
|
1110
|
+
if var not in self.nodes and var not in self.config:
|
|
1111
|
+
# checking for runtime_inputs because it's not in the graph isn't really a graph concern. So perhaps we
|
|
1112
|
+
# should move this outside of the graph in the future. This will do fine for now.
|
|
1113
|
+
if var not in runtime_inputs:
|
|
1114
|
+
# if it's not in the runtime inputs, it's a properly missing variable
|
|
1115
|
+
missing_vars.append(var)
|
|
1116
|
+
continue # collect all missing final variables
|
|
1117
|
+
dfs_traverse_iterative(self.nodes[var])
|
|
1118
|
+
if missing_vars:
|
|
1119
|
+
missing_vars_str = ",\n".join(missing_vars)
|
|
1120
|
+
raise ValueError(f"Unknown nodes [{missing_vars_str}] requested. Check for typos?")
|
|
1121
|
+
return nodes, user_nodes
|
|
1122
|
+
|
|
1123
|
+
def execute(
|
|
1124
|
+
self,
|
|
1125
|
+
nodes: Collection[node.Node] = None,
|
|
1126
|
+
computed: dict[str, Any] = None,
|
|
1127
|
+
overrides: dict[str, Any] = None,
|
|
1128
|
+
inputs: dict[str, Any] = None,
|
|
1129
|
+
run_id: str = None,
|
|
1130
|
+
) -> dict[str, Any]:
|
|
1131
|
+
"""Executes the DAG, given potential inputs/previously computed components.
|
|
1132
|
+
|
|
1133
|
+
:param nodes: Nodes to compute
|
|
1134
|
+
:param computed: Nodes that have already been computed
|
|
1135
|
+
:param overrides: Overrides for nodes in the DAG
|
|
1136
|
+
:param inputs: Inputs to the DAG -- have to be disjoint from config.
|
|
1137
|
+
:return: The result of executing the DAG (a dict of node name to node result)
|
|
1138
|
+
"""
|
|
1139
|
+
if nodes is None:
|
|
1140
|
+
nodes = self.get_nodes()
|
|
1141
|
+
if inputs is None:
|
|
1142
|
+
inputs = {}
|
|
1143
|
+
if run_id is None:
|
|
1144
|
+
run_id = str(uuid.uuid4())
|
|
1145
|
+
inputs = graph_functions.combine_config_and_inputs(self.config, inputs)
|
|
1146
|
+
return graph_functions.execute_subdag(
|
|
1147
|
+
nodes=nodes,
|
|
1148
|
+
inputs=inputs,
|
|
1149
|
+
adapter=self.adapter,
|
|
1150
|
+
computed=computed,
|
|
1151
|
+
overrides=overrides,
|
|
1152
|
+
run_id=run_id,
|
|
1153
|
+
)
|