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,328 @@
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 textwrap
22
+ import threading
23
+ import time
24
+
25
+ from fastmcp import FastMCP
26
+
27
+ from hamilton.plugins.h_mcp._helpers import (
28
+ build_driver_from_code,
29
+ cleanup_temp_module,
30
+ format_exception_chain,
31
+ serialize_results,
32
+ )
33
+ from hamilton.plugins.h_mcp._templates import get_available_templates, get_capabilities
34
+
35
+ mcp = FastMCP(
36
+ name="Hamilton",
37
+ instructions=(
38
+ "Hamilton is a Python micro-framework where functions define DAG nodes. "
39
+ "Ask the user which data libraries they use (e.g., pandas, numpy, polars) "
40
+ "then call hamilton_capabilities with their preferred_libraries. "
41
+ "Use hamilton_validate_dag to check code before execution. "
42
+ "Workflow: ask user -> capabilities -> scaffold -> validate -> visualize -> correct -> execute."
43
+ ),
44
+ )
45
+
46
+
47
+ @mcp.tool()
48
+ def hamilton_validate_dag(code: str, config: dict | None = None) -> dict:
49
+ """Validate Hamilton DAG code by building the Driver.
50
+
51
+ Compiles Python source into a Hamilton DAG and checks for missing
52
+ dependencies, type mismatches, and circular references -- all without
53
+ executing the code.
54
+
55
+ Returns ``{"valid": true, "node_count": N, "nodes": [...], "inputs": [...]}``
56
+ on success or ``{"valid": false, "errors": [...]}`` on failure.
57
+ """
58
+ module = None
59
+ try:
60
+ dr, module = build_driver_from_code(code, config)
61
+ variables = dr.list_available_variables()
62
+ nodes = [v.name for v in variables if not v.is_external_input]
63
+ inputs = [v.name for v in variables if v.is_external_input]
64
+ return {
65
+ "valid": True,
66
+ "node_count": len(nodes),
67
+ "nodes": sorted(nodes),
68
+ "inputs": sorted(inputs),
69
+ "errors": [],
70
+ }
71
+ except Exception as exc:
72
+ return {
73
+ "valid": False,
74
+ "node_count": 0,
75
+ "nodes": [],
76
+ "inputs": [],
77
+ "errors": format_exception_chain(exc),
78
+ }
79
+ finally:
80
+ if module is not None:
81
+ cleanup_temp_module(module)
82
+
83
+
84
+ @mcp.tool()
85
+ def hamilton_list_nodes(code: str, config: dict | None = None) -> dict:
86
+ """List all nodes in a Hamilton DAG with their types and dependencies.
87
+
88
+ Builds the DAG from source, then returns structured info for every node
89
+ including name, output type, tags, whether it is an external input,
90
+ and its required/optional dependencies.
91
+ """
92
+ module = None
93
+ try:
94
+ dr, module = build_driver_from_code(code, config)
95
+ variables = dr.list_available_variables()
96
+ from hamilton.htypes import get_type_as_string
97
+
98
+ node_list = []
99
+ for v in variables:
100
+ node_list.append(
101
+ {
102
+ "name": v.name,
103
+ "type": get_type_as_string(v.type) or "",
104
+ "is_external_input": v.is_external_input,
105
+ "tags": v.tags,
106
+ "required_dependencies": sorted(v.required_dependencies),
107
+ "optional_dependencies": sorted(v.optional_dependencies),
108
+ "documentation": v.documentation,
109
+ }
110
+ )
111
+ return {"nodes": node_list, "errors": []}
112
+ except Exception as exc:
113
+ return {"nodes": [], "errors": format_exception_chain(exc)}
114
+ finally:
115
+ if module is not None:
116
+ cleanup_temp_module(module)
117
+
118
+
119
+ @mcp.tool()
120
+ def hamilton_visualize(code: str, config: dict | None = None, output_format: str = "dot") -> str:
121
+ """Visualize the Hamilton DAG as DOT graph source.
122
+
123
+ Builds the DAG and returns a Graphviz DOT-language string describing
124
+ the dependency graph. Requires ``graphviz`` (``pip install "apache-hamilton[visualization]"``).
125
+ """
126
+ module = None
127
+ try:
128
+ dr, module = build_driver_from_code(code, config)
129
+ try:
130
+ dot = dr.display_all_functions(render_kwargs={"view": False})
131
+ except ImportError:
132
+ return (
133
+ "Error: graphviz is required for visualization. "
134
+ 'Install with: pip install "apache-hamilton[visualization]"'
135
+ )
136
+ if dot is None:
137
+ return (
138
+ "Error: graphviz is required for visualization. "
139
+ 'Install with: pip install "apache-hamilton[visualization]"'
140
+ )
141
+ return dot.source
142
+ except Exception as exc:
143
+ return f"Error: {exc}"
144
+ finally:
145
+ if module is not None:
146
+ cleanup_temp_module(module)
147
+
148
+
149
+ @mcp.tool()
150
+ def hamilton_execute(
151
+ code: str,
152
+ final_vars: list[str],
153
+ inputs: dict | None = None,
154
+ config: dict | None = None,
155
+ timeout_seconds: int = 30,
156
+ ) -> dict:
157
+ """Execute a Hamilton DAG and return the requested outputs.
158
+
159
+ Builds the DAG from source, then calls ``driver.execute()`` with the
160
+ given ``final_vars`` and ``inputs``. Results are serialized to JSON-safe
161
+ strings. A timeout (default 30s) guards against long-running code.
162
+
163
+ WARNING: This executes arbitrary Python code.
164
+ """
165
+ module = None
166
+ result_container: dict = {}
167
+ error_container: dict = {}
168
+
169
+ def _run(dr, final_vars, inputs):
170
+ try:
171
+ result_container["results"] = dr.execute(final_vars=final_vars, inputs=inputs or {})
172
+ except Exception as exc:
173
+ error_container["error"] = exc
174
+
175
+ try:
176
+ dr, module = build_driver_from_code(code, config)
177
+
178
+ start = time.monotonic()
179
+ worker = threading.Thread(target=_run, args=(dr, final_vars, inputs))
180
+ worker.start()
181
+ worker.join(timeout=timeout_seconds)
182
+
183
+ if worker.is_alive():
184
+ return {"error": f"Execution timed out after {timeout_seconds}s"}
185
+
186
+ elapsed_ms = round((time.monotonic() - start) * 1000, 1)
187
+
188
+ if "error" in error_container:
189
+ return {
190
+ "error": str(error_container["error"]),
191
+ "execution_time_ms": elapsed_ms,
192
+ }
193
+
194
+ return {
195
+ "results": serialize_results(result_container["results"]),
196
+ "execution_time_ms": elapsed_ms,
197
+ }
198
+ except Exception as exc:
199
+ return {"error": str(exc)}
200
+ finally:
201
+ if module is not None:
202
+ cleanup_temp_module(module)
203
+
204
+
205
+ @mcp.tool()
206
+ def hamilton_get_docs(topic: str) -> str:
207
+ """Get Hamilton documentation for a specific topic.
208
+
209
+ Supported topics: ``overview``, ``decorators``, ``driver``, ``builder``,
210
+ or any decorator name such as ``parameterize``, ``extract_columns``,
211
+ ``config``, ``check_output``, ``tag``, ``pipe``, ``does``, ``subdag``, etc.
212
+ """
213
+ topic = topic.strip().lower()
214
+
215
+ if topic == "overview":
216
+ return textwrap.dedent("""\
217
+ Hamilton -- Python micro-framework for dataflow DAGs
218
+
219
+ Core concepts:
220
+ - Each Python function defines a DAG node.
221
+ - The function name becomes the node name.
222
+ - Function parameters declare dependencies on other nodes.
223
+ - Return type annotations are required.
224
+ - The Driver compiles functions into a DAG, validates dependencies
225
+ and types at build time, then executes the graph.
226
+
227
+ Quick start:
228
+ 1. Write functions in a module (my_functions.py).
229
+ 2. Build a Driver:
230
+ from hamilton import driver
231
+ import my_functions
232
+ dr = driver.Builder().with_modules(my_functions).build()
233
+ 3. Execute:
234
+ results = dr.execute(["output_node"], inputs={...})
235
+
236
+ Key decorators: @parameterize, @extract_columns, @config.when,
237
+ @check_output, @tag, @pipe, @does, @subdag
238
+ """)
239
+
240
+ if topic == "decorators":
241
+ from hamilton import function_modifiers
242
+
243
+ decorators = {
244
+ "parameterize": "Create multiple nodes from one function with different parameters.",
245
+ "parameterize_sources": "Parameterize by mapping different source nodes.",
246
+ "parameterize_values": "Parameterize by mapping different literal values.",
247
+ "extract_columns": "Expand a DataFrame-returning function into per-column nodes.",
248
+ "extract_fields": "Expand a dict-returning function into per-field nodes.",
249
+ "config.when": "Conditionally include a function based on config values.",
250
+ "check_output": "Attach data quality validators to a node's output.",
251
+ "tag": "Add metadata tags to a node.",
252
+ "tag_outputs": "Tag specific outputs of a multi-output function.",
253
+ "pipe": "Chain transforms: pass a node's output through a pipeline.",
254
+ "does": "Replace function body with another callable.",
255
+ "subdag": "Include an entire sub-DAG as a namespace.",
256
+ "inject": "Inject specific values or sources into function parameters.",
257
+ "schema": "Attach schema metadata to a node.",
258
+ "cache": "Mark a node for caching.",
259
+ "load_from": "Load data from an external source (data loader).",
260
+ "save_to": "Save data to an external destination (data saver).",
261
+ }
262
+ lines = ["Available Hamilton decorators:\n"]
263
+ for name, desc in decorators.items():
264
+ lines.append(f" @{name} -- {desc}")
265
+ lines.append("\nUse hamilton_get_docs('<decorator_name>') for full documentation.")
266
+ return "\n".join(lines)
267
+
268
+ if topic in ("driver", "builder"):
269
+ from hamilton import driver as driver_mod
270
+
271
+ doc = inspect.getdoc(driver_mod.Builder)
272
+ return doc or "No documentation found for Builder."
273
+
274
+ # Try to find a decorator by name in function_modifiers
275
+ from hamilton import function_modifiers
276
+
277
+ obj = getattr(function_modifiers, topic, None)
278
+ if obj is not None:
279
+ doc = inspect.getdoc(obj)
280
+ if doc:
281
+ return f"@{topic}\n\n{doc}"
282
+ # For class-based decorators, try the class itself
283
+ if isinstance(obj, type):
284
+ doc = inspect.getdoc(obj)
285
+ if doc:
286
+ return f"@{topic}\n\n{doc}"
287
+
288
+ return (
289
+ f"Unknown topic '{topic}'. "
290
+ "Supported: overview, decorators, driver, builder, "
291
+ "or any decorator name (parameterize, extract_columns, config, "
292
+ "check_output, tag, pipe, does, subdag, etc.)"
293
+ )
294
+
295
+
296
+ @mcp.tool()
297
+ def hamilton_capabilities(preferred_libraries: list[str] | None = None) -> dict:
298
+ """Report which optional libraries are installed and which features are available.
299
+
300
+ Call this first to discover the environment before generating code.
301
+ If the user specifies which libraries they use, pass them as
302
+ ``preferred_libraries`` (e.g., ``["pandas", "numpy"]``) to filter
303
+ scaffold patterns accordingly.
304
+ """
305
+ prefs = set(preferred_libraries) if preferred_libraries is not None else None
306
+ return get_capabilities(prefs)
307
+
308
+
309
+ @mcp.tool()
310
+ def hamilton_scaffold(
311
+ pattern: str,
312
+ preferred_libraries: list[str] | None = None,
313
+ ) -> str:
314
+ """Generate a starter Hamilton module for a given pattern.
315
+
316
+ Available patterns depend on installed or preferred libraries.
317
+ Pass ``preferred_libraries`` to filter to patterns matching the
318
+ user's environment (e.g., ``["pandas"]``).
319
+ """
320
+ prefs = set(preferred_libraries) if preferred_libraries is not None else None
321
+ templates = get_available_templates(prefs)
322
+ pattern = pattern.strip().lower()
323
+ template = templates.get(pattern)
324
+ if template is None:
325
+ available = ", ".join(sorted(templates.keys()))
326
+ return f"Unknown pattern '{pattern}'. Available patterns: {available}"
327
+
328
+ return template.code
@@ -0,0 +1,335 @@
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 pickle
20
+ import warnings
21
+ from typing import Any
22
+
23
+ import mlflow
24
+ import mlflow.data
25
+
26
+ from hamilton import graph_types
27
+ from hamilton.lifecycle import GraphConstructionHook, GraphExecutionHook, NodeExecutionHook
28
+
29
+ # silence odd ongoing MLFlow issue that spams warnings
30
+ # GitHub Issue https://github.com/mlflow/mlflow/issues/8605
31
+ warnings.filterwarnings("ignore", category=UserWarning)
32
+
33
+
34
+ FIGURE_TYPES = []
35
+ try:
36
+ import matplotlib.figure
37
+
38
+ FIGURE_TYPES.append(matplotlib.figure.Figure)
39
+ except ImportError:
40
+ pass
41
+
42
+ try:
43
+ import plotly.graph_objects
44
+
45
+ FIGURE_TYPES.append(plotly.graph_objects.Figure)
46
+ except ImportError:
47
+ pass
48
+
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def get_path_from_metadata(metadata: dict) -> str | None:
54
+ """Retrieve the `path` attribute from DataSaver output metadata"""
55
+ path = None
56
+ if "path" in metadata:
57
+ path = metadata["path"]
58
+ elif "file_metadata" in metadata:
59
+ path = metadata["file_metadata"]["path"]
60
+
61
+ return path
62
+
63
+
64
+ # NOTE `mlflow.client.MLFlowClient` is preferred to top-level `mlflow.` methods in MLFlowTracker
65
+ # because the latter relies on hard-to-debug global variables. Yet, we set an `active_run` by using
66
+ # `mlflow.start_run()` in pre_graph_execution to ensure the user-specified MLFlow code
67
+ # and MLFlow materializers log metrics and models to the same run as the MLFlowTracker
68
+ class MLFlowTracker(
69
+ NodeExecutionHook,
70
+ GraphExecutionHook,
71
+ GraphConstructionHook,
72
+ ):
73
+ """Driver adapter logging Hamilton execution results to an MLFlow server."""
74
+
75
+ def __init__(
76
+ self,
77
+ tracking_uri: str | None = None,
78
+ registry_uri: str | None = None,
79
+ artifact_location: str | None = None,
80
+ experiment_name: str = "Hamilton",
81
+ experiment_tags: dict | None = None,
82
+ experiment_description: str | None = None,
83
+ run_id: str | None = None,
84
+ run_name: str | None = None,
85
+ run_tags: dict | None = None,
86
+ run_description: str | None = None,
87
+ log_system_metrics: bool = False,
88
+ ):
89
+ """Configure the MLFlow client and experiment for the lifetime of the tracker
90
+
91
+ :param tracking_uri: Destination of the logged artifacts and metadata. It can be a filesystem, database, or server. [reference](https://mlflow.org/docs/latest/getting-started/tracking-server-overview/index.html)
92
+ :param registry_uri: Destination of the registered models. By default it's the same as the tracking destination, but they can be different. [reference](https://mlflow.org/docs/latest/getting-started/registering-first-model/index.html)
93
+ :param artifact_location: Root path on tracking server where experiment is stored
94
+ :param experiment_name: MLFlow experiment name used to group runs.
95
+ :param experiment_tags: Tags to query experiments programmatically (not displayed).
96
+ :param experiment_description: Description of the experiment displayed
97
+ :param run_id: Run id to log to an existing run (every execution logs to the same run)
98
+ :param run_name: Run name displayed and used to query runs. You can have multiple runs with the same name but different run ids.
99
+ :param run_tags: Tags to query runs and appears as columns in the UI for filtering and grouping. It automatically includes serializable inputs and Driver config.
100
+ :param run_description: Description of the run displayed
101
+ :param log_system_metrics: Log system metrics to display (requires additonal dependencies)
102
+ """
103
+ self.client = mlflow.client.MlflowClient(tracking_uri, registry_uri)
104
+
105
+ # experiment setup
106
+ experiment_tags = experiment_tags or {}
107
+ if experiment_description:
108
+ # mlflow.note.content is the description field
109
+ experiment_tags["mlflow.note.content"] = experiment_description
110
+
111
+ # TODO link HamiltonTracker project and MLFlowTracker experiment
112
+ experiment = self.client.get_experiment_by_name(experiment_name)
113
+ if experiment:
114
+ experiment_id = experiment.experiment_id
115
+ # update tags and description of an existing experiment
116
+ if experiment_tags:
117
+ for k, v in experiment_tags.items():
118
+ self.client.set_experiment_tag(experiment_id, key=k, value=v)
119
+ # create an experiment
120
+ else:
121
+ experiment_id = self.client.create_experiment(
122
+ name=experiment_name,
123
+ artifact_location=artifact_location,
124
+ tags=experiment_tags,
125
+ )
126
+ self.experiment_id = experiment_id
127
+
128
+ # run setup
129
+ # TODO link HamiltonTracker and MLFlowTracker run ids
130
+ self.mlflow_run_id = run_id
131
+ self.run_name = run_name
132
+ self.run_tags = run_tags or {}
133
+ if run_description:
134
+ # mlflow.note.content is the description field
135
+ self.run_tags["mlflow.note.content"] = run_description
136
+
137
+ self.log_system_metrics = log_system_metrics
138
+
139
+ def run_after_graph_construction(self, *, config: dict[str, Any], **kwargs):
140
+ """Store the Driver config before creating the graph"""
141
+ self.config = config
142
+
143
+ def run_before_graph_execution(
144
+ self,
145
+ *,
146
+ run_id: str,
147
+ final_vars: list[str],
148
+ inputs: dict[str, Any],
149
+ graph: graph_types.HamiltonGraph,
150
+ **kwargs,
151
+ ):
152
+ """Create and start MLFlow run. Log graph version, run_id, inputs, overrides"""
153
+ # add Hamilton metadata to run tags
154
+ run_tags = self.run_tags
155
+ run_tags["hamilton_run_id"] = run_id # the Hamilton run_id
156
+ run_tags["code_version"] = graph.version
157
+
158
+ # create Hamilton run
159
+ self.run = self.client.create_run(
160
+ experiment_id=self.experiment_id,
161
+ tags=run_tags,
162
+ run_name=self.run_name,
163
+ )
164
+ self.run_id = self.run.info.run_id
165
+ # start run to set `active_run` and allow user-defined callbacks and materializers
166
+ # to log to the same run as the HamiltonTracker
167
+ mlflow.start_run(
168
+ run_id=self.run_id,
169
+ experiment_id=self.experiment_id,
170
+ tags=run_tags,
171
+ log_system_metrics=self.log_system_metrics,
172
+ )
173
+
174
+ # log config to artifacts
175
+ self.client.log_dict(self.run_id, self.config, "config.json")
176
+
177
+ # log HamiltonGraph to reproduce the run
178
+ self.graph = graph
179
+ graph_as_json = {n.name: n.as_dict() for n in graph.nodes}
180
+ self.client.log_dict(self.run_id, graph_as_json, "hamilton_graph.json")
181
+
182
+ # log config and inputs as `param` which creates columns in the UI to filter runs
183
+ # `log_param()` accepts `value: Any` and will stringify complex objects
184
+ for value_sets in [self.config, inputs]:
185
+ for node_name, value in value_sets.items():
186
+ self.client.log_param(self.run_id, key=node_name, value=value)
187
+
188
+ self.final_vars = final_vars
189
+
190
+ # TODO log DataLoaders as MLFlow datasets
191
+ def run_after_node_execution(
192
+ self,
193
+ *,
194
+ node_name: str,
195
+ node_return_type: type,
196
+ node_tags: dict,
197
+ node_kwargs: dict,
198
+ result: Any,
199
+ **kwargs,
200
+ ):
201
+ """Log materializers and final vars as artifacts"""
202
+ # log DataSavers as artifacts
203
+ # TODO refactor if/else as `handle_materializers()`
204
+ if node_tags.get("hamilton.data_saver") is True:
205
+ # don't log mlflow materializers as artifact since they already create models
206
+ # instead, use the Materializer metadata to add metadata to registered models
207
+ if node_tags["hamilton.data_saver.sink"] == "mlflow":
208
+ # skip if not registered model
209
+ if "registered_model" not in result.keys():
210
+ return
211
+
212
+ # get the registered model name (param of MLFlowModelSaver)
213
+ model_name = result["registered_model"]["name"]
214
+ version = result["registered_model"]["version"]
215
+ materializer_node = self.graph[node_name]
216
+ # get the "materialized node" defining the model
217
+ materialized_node = self.graph[materializer_node.required_dependencies.pop()]
218
+ # add the materialized node docstring as description
219
+ # registered models have multiple versions
220
+ self.client.update_registered_model(model_name, materialized_node.documentation)
221
+ self.client.update_model_version(
222
+ model_name, version, materialized_node.documentation
223
+ )
224
+
225
+ # add node name as tag
226
+ self.client.set_registered_model_tag(
227
+ model_name, key="node_name", value=materialized_node.name
228
+ )
229
+ self.client.set_model_version_tag(
230
+ model_name, version, key="node_name", value=materialized_node.name
231
+ )
232
+
233
+ # add origin function name as tag
234
+ self.client.set_registered_model_tag(
235
+ model_name,
236
+ key="function_name",
237
+ value=materialized_node.originating_functions[0].__name__,
238
+ )
239
+ self.client.set_model_version_tag(
240
+ model_name,
241
+ version,
242
+ key="function_name",
243
+ value=materialized_node.originating_functions[0].__name__,
244
+ )
245
+
246
+ # add the materialized node @tag values as tags
247
+ for k, v in materialized_node.tags.items():
248
+ # skip internal Hamilton tags
249
+ if "hamilton." in k:
250
+ continue
251
+ self.client.set_registered_model_tag(model_name, key=k, value=v)
252
+ self.client.set_model_version_tag(model_name, version, key=k, value=v)
253
+ # TODO automatically collect model input signature; maybe simpler from user code
254
+
255
+ # special case for matplotlib and plotly
256
+ # log materialized figure. Allows great degree of control over rendering format
257
+ # and also save interactive plotly visualization as HTML
258
+ elif node_tags["hamilton.data_saver.sink"] in ["plt", "plotly"]:
259
+ materializer_node = self.graph[node_name]
260
+ materialized_node = self.graph[materializer_node.required_dependencies.pop()]
261
+ figure = node_kwargs[materialized_node.name]
262
+
263
+ path = get_path_from_metadata(result)
264
+ if path:
265
+ self.client.log_figure(self.run_id, figure, path)
266
+ else:
267
+ logger.warning(
268
+ f"Materialization result from node={node_name} has no recordable path: {result}. Materializer must have either "
269
+ f"'path' or 'file_metadata' keys."
270
+ )
271
+
272
+ else:
273
+ # log the materializer path as an artifact
274
+ path = get_path_from_metadata(result)
275
+ if path:
276
+ self.client.log_artifact(self.run_id, path, node_name)
277
+ else:
278
+ logger.warning(
279
+ f"Materialization result from node={node_name} has no recordable path: {result}. Materializer must have either "
280
+ f"'path' or 'file_metadata' keys."
281
+ )
282
+ return
283
+
284
+ # log final_vars as artifacts
285
+ if node_name not in self.final_vars:
286
+ return
287
+
288
+ # TODO refactor if/else as `handle_final_vars()`
289
+ # log float and int as metrics
290
+ if node_return_type in [float, int]:
291
+ self.client.log_metric(self.run_id, key=node_name, value=float(result))
292
+
293
+ # log str as text in .txt format
294
+ elif isinstance(node_return_type, str):
295
+ file_path = f"{node_name}.txt"
296
+ with open(file_path, "w") as f:
297
+ f.write(result)
298
+ self.client.log_text(self.run_id, result, file_path)
299
+
300
+ # log_dict (JSON) dictionary types; pickle if not json-serializable
301
+ elif isinstance(node_return_type, dict):
302
+ try:
303
+ file_path = f"{node_name}.json"
304
+ self.client.log_dict(self.run_id, result, file_path)
305
+ # not json-serializable
306
+ except TypeError:
307
+ file_path = f"{node_name}.pickle"
308
+ with open(file_path, "wb") as f:
309
+ pickle.dump(result, file=f)
310
+ self.client.log_dict(self.run_id, result, file_path)
311
+
312
+ # this puts less burden on users by not having to define materializers
313
+ # for viz, but less control over rendering format
314
+ elif node_return_type in FIGURE_TYPES:
315
+ file_path = f"{node_name}.png"
316
+ self.client.log_figure(self.run_id, result, file_path)
317
+
318
+ # default to log_artifact in .pickle format
319
+ else:
320
+ file_path = f"{node_name}.pickle"
321
+ with open(file_path, "wb") as f:
322
+ pickle.dump(result, f)
323
+ self.client.log_dict(self.run_id, result, file_path)
324
+
325
+ def run_after_graph_execution(self, success: bool, *args, **kwargs):
326
+ """End the MLFlow run"""
327
+ # `status` is an enum value of mlflow.entities.RunStatus
328
+ if success:
329
+ self.client.set_terminated(self.run_id, status="FINISHED")
330
+ else:
331
+ self.client.set_terminated(self.run_id, status="FAILED")
332
+ mlflow.end_run()
333
+
334
+ def run_before_node_execution(self, *args, **kwargs):
335
+ """Placeholder required to subclass NodeExecutionHook"""