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
|
@@ -0,0 +1,493 @@
|
|
|
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 enum
|
|
19
|
+
import functools
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Literal, NamedTuple
|
|
25
|
+
|
|
26
|
+
import pyarrow
|
|
27
|
+
import pyarrow.ipc
|
|
28
|
+
from pyarrow.interchange import from_dataframe
|
|
29
|
+
|
|
30
|
+
from hamilton.experimental import h_databackends
|
|
31
|
+
from hamilton.graph_types import HamiltonGraph, HamiltonNode
|
|
32
|
+
from hamilton.lifecycle import GraphExecutionHook, NodeExecutionHook
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Diff(enum.Enum):
|
|
38
|
+
"""Result of a diff operation: ADDED, REMOVED, EQUAL, or UNEQUAL. They are mutually exclusive."""
|
|
39
|
+
|
|
40
|
+
ADDED = "+"
|
|
41
|
+
REMOVED = "-"
|
|
42
|
+
EQUAL = "=="
|
|
43
|
+
UNEQUAL = "!="
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DiffResult(NamedTuple):
|
|
47
|
+
diff: Diff
|
|
48
|
+
value: Any
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _diff_mappings(
|
|
52
|
+
current: Mapping[str, Any], reference: Mapping[str, Any]
|
|
53
|
+
) -> dict[str, DiffResult]:
|
|
54
|
+
"""Generate the diff for all fields of two mappings.
|
|
55
|
+
|
|
56
|
+
example:
|
|
57
|
+
{
|
|
58
|
+
"foo": {DiffResult.ADDED: "foo_value"},
|
|
59
|
+
"bar": {DiffResult.REMOVED: "bar_value"},
|
|
60
|
+
"baz": {DiffResult.EQUAL: "baz_value"},
|
|
61
|
+
"oof": {DiffResult.UNEQUAL: {"cur": "oof_value", "ref": "old_value"}}
|
|
62
|
+
}
|
|
63
|
+
"""
|
|
64
|
+
current_key_set = set(current.keys())
|
|
65
|
+
reference_key_set = set(reference.keys())
|
|
66
|
+
|
|
67
|
+
current_only = current_key_set.difference(reference_key_set)
|
|
68
|
+
reference_only = reference_key_set.difference(current_key_set)
|
|
69
|
+
shared = current_key_set.intersection(reference_key_set)
|
|
70
|
+
|
|
71
|
+
diff = {}
|
|
72
|
+
for key in current_only:
|
|
73
|
+
diff[key] = DiffResult(Diff.ADDED, current[key])
|
|
74
|
+
|
|
75
|
+
for key in reference_only:
|
|
76
|
+
diff[key] = DiffResult(Diff.REMOVED, reference[key])
|
|
77
|
+
|
|
78
|
+
for key in shared:
|
|
79
|
+
current_value = current[key]
|
|
80
|
+
reference_value = reference[key]
|
|
81
|
+
|
|
82
|
+
if current_value == reference_value:
|
|
83
|
+
diff[key] = DiffResult(Diff.EQUAL, current_value)
|
|
84
|
+
else:
|
|
85
|
+
diff[key] = DiffResult(Diff.UNEQUAL, {"cur": current_value, "ref": reference_value})
|
|
86
|
+
|
|
87
|
+
return diff
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
SCHEMA_METADATA_FIELD = "__metadata"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# TODO add check for field metadata
|
|
94
|
+
def diff_schemas(
|
|
95
|
+
current_schema: pyarrow.Schema,
|
|
96
|
+
reference_schema: pyarrow.Schema,
|
|
97
|
+
check_schema_metadata: bool = False,
|
|
98
|
+
check_field_metadata: bool = False,
|
|
99
|
+
):
|
|
100
|
+
"""Diff two Pyarrow schema field-by-field. Options to diff schema and field metadata key-by-key.
|
|
101
|
+
Returning an empty dict means equality / no diff
|
|
102
|
+
|
|
103
|
+
example:
|
|
104
|
+
{
|
|
105
|
+
'__metadata': DiffResult(diff=<Diff.UNEQUAL: '!='>, value={
|
|
106
|
+
'key': DiffResult(diff=<Diff.UNEQUAL: '!='>, value={
|
|
107
|
+
'cur': 'value1', 'ref': 'value2'
|
|
108
|
+
})
|
|
109
|
+
}),
|
|
110
|
+
'bar': DiffResult(diff=<Diff.EQUAL: '=='>, value={
|
|
111
|
+
'name': 'bar', 'type': 'int64', 'nullable': True, 'metadata': {}
|
|
112
|
+
}),
|
|
113
|
+
'foo': DiffResult(diff=<Diff.UNEQUAL: '!='>, value={
|
|
114
|
+
'name': DiffResult(diff=<Diff.EQUAL: '=='>, value='foo')
|
|
115
|
+
'type': DiffResult(diff=<Diff.EQUAL: '=='>, value='string'),
|
|
116
|
+
'nullable': DiffResult(diff=<Diff.EQUAL: '=='>, value=True),
|
|
117
|
+
'metadata': DiffResult(diff=<Diff.UNEQUAL: '!='>, value={
|
|
118
|
+
'key': DiffResult(diff=<Diff.UNEQUAL: '!='>, value={
|
|
119
|
+
'cur': 'value1', 'ref': 'value2'
|
|
120
|
+
})
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
# if schemas are equal, return an empty diff
|
|
127
|
+
if current_schema.equals(
|
|
128
|
+
reference_schema, check_metadata=(check_schema_metadata or check_field_metadata)
|
|
129
|
+
):
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
current_schema = pyarrow_schema_to_json(current_schema)
|
|
133
|
+
reference_schema = pyarrow_schema_to_json(reference_schema)
|
|
134
|
+
|
|
135
|
+
schema_diff = _diff_mappings(current=current_schema, reference=reference_schema)
|
|
136
|
+
|
|
137
|
+
# compare fields shared by both schemas
|
|
138
|
+
for field_name, diff_result in schema_diff.items():
|
|
139
|
+
if diff_result.diff == Diff.UNEQUAL:
|
|
140
|
+
current_field = current_schema.get(field_name, {})
|
|
141
|
+
reference_field = reference_schema.get(field_name, {})
|
|
142
|
+
|
|
143
|
+
field_diff = DiffResult(
|
|
144
|
+
diff=Diff.UNEQUAL,
|
|
145
|
+
value=_diff_mappings(current=current_field, reference=reference_field),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if check_field_metadata and (field_diff.value["metadata"].diff != Diff.EQUAL):
|
|
149
|
+
current_field_metadata = current_field.get("metadata", {})
|
|
150
|
+
reference_field_metadata = reference_field.get("metadata", {})
|
|
151
|
+
|
|
152
|
+
field_diff.value["metadata"] = DiffResult(
|
|
153
|
+
diff=Diff.UNEQUAL,
|
|
154
|
+
value=_diff_mappings(
|
|
155
|
+
current=current_field_metadata,
|
|
156
|
+
reference=reference_field_metadata,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
schema_diff[field_name] = field_diff
|
|
161
|
+
|
|
162
|
+
# compare schema metadata
|
|
163
|
+
if check_schema_metadata:
|
|
164
|
+
current_schema_metadata = current_schema.get(SCHEMA_METADATA_FIELD, {})
|
|
165
|
+
reference_schema_metadata = reference_schema.get(SCHEMA_METADATA_FIELD, {})
|
|
166
|
+
|
|
167
|
+
schema_diff[SCHEMA_METADATA_FIELD] = DiffResult(
|
|
168
|
+
Diff.UNEQUAL,
|
|
169
|
+
_diff_mappings(current=current_schema_metadata, reference=reference_schema_metadata),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return schema_diff
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def human_readable_diff(diff: dict) -> dict:
|
|
176
|
+
"""Format a diff to exclude EQUAL fields and make it easier to read.
|
|
177
|
+
|
|
178
|
+
example:
|
|
179
|
+
{
|
|
180
|
+
'__metadata': {
|
|
181
|
+
'key': {'cur': 'value1', 'ref': 'value2'}
|
|
182
|
+
},
|
|
183
|
+
"foo": {
|
|
184
|
+
"metadata": {
|
|
185
|
+
'key': {"cur": "value1", "ref": "value2"}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
"baz": "-",
|
|
189
|
+
"bar": {
|
|
190
|
+
"type": {"cur": "int64", "ref": "double"}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
readable_diff = {}
|
|
196
|
+
|
|
197
|
+
for field_name, diff_result in diff.items():
|
|
198
|
+
# special case for the schema metadata field
|
|
199
|
+
if field_name == SCHEMA_METADATA_FIELD:
|
|
200
|
+
schema_metadata_diff = human_readable_diff(diff_result.value)
|
|
201
|
+
if schema_metadata_diff != {}:
|
|
202
|
+
readable_diff[SCHEMA_METADATA_FIELD] = schema_metadata_diff
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if diff_result.diff == Diff.EQUAL:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
elif diff_result.diff == Diff.ADDED:
|
|
209
|
+
readable_diff[field_name] = diff_result.diff.value
|
|
210
|
+
|
|
211
|
+
elif diff_result.diff == Diff.REMOVED:
|
|
212
|
+
readable_diff[field_name] = diff_result.diff.value
|
|
213
|
+
|
|
214
|
+
elif diff_result.diff == Diff.UNEQUAL:
|
|
215
|
+
if diff_result.value.get("cur"):
|
|
216
|
+
readable_diff[field_name] = diff_result.value
|
|
217
|
+
else:
|
|
218
|
+
readable_diff[field_name] = human_readable_diff(diff_result.value)
|
|
219
|
+
|
|
220
|
+
return dict(sorted(readable_diff.items()))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def pyarrow_schema_to_json(schema: pyarrow.Schema) -> dict:
|
|
224
|
+
"""Convert a pyarrow.Schema to a JSON-serializable dictionary
|
|
225
|
+
|
|
226
|
+
Pyarrow provides a schema-to-string, but not schema-to-json.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
schema_dict = dict()
|
|
230
|
+
|
|
231
|
+
if schema.metadata:
|
|
232
|
+
schema_dict[SCHEMA_METADATA_FIELD] = {
|
|
233
|
+
k.decode(): v.decode() for k, v in schema.metadata.items()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for name in schema.names:
|
|
237
|
+
field = schema.field(name)
|
|
238
|
+
field_metadata = {}
|
|
239
|
+
if field.metadata:
|
|
240
|
+
field_metadata = {k.decode(): v.decode() for k, v in field.metadata.items()}
|
|
241
|
+
|
|
242
|
+
schema_dict[str(name)] = dict(
|
|
243
|
+
name=field.name,
|
|
244
|
+
type=field.type.__str__(), # __str__() and __repr__() of pyarrow.Field are different
|
|
245
|
+
nullable=field.nullable,
|
|
246
|
+
metadata=field_metadata,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return schema_dict
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@functools.singledispatch
|
|
253
|
+
def _get_arrow_schema(df, allow_copy: bool = True) -> pyarrow.Schema:
|
|
254
|
+
"""Base case for getting a pyarrow schema from a dataframe.
|
|
255
|
+
|
|
256
|
+
:param allow_copy: If True, allow to convert the object to Pyarrow
|
|
257
|
+
even if zero-copy is unavailable
|
|
258
|
+
|
|
259
|
+
It looks for the `__dataframe__` attribute associated with the dataframe interchange protocol
|
|
260
|
+
ref: https://data-apis.org/dataframe-protocol/latest/API.html
|
|
261
|
+
"""
|
|
262
|
+
if not hasattr(df, "__dataframe__"):
|
|
263
|
+
# if hitting this condition, we can register a new function
|
|
264
|
+
# to conver the dataframe to pyarrow
|
|
265
|
+
raise NotImplementedError(f"Type {type(df)} is currently unsupported.")
|
|
266
|
+
|
|
267
|
+
# try to convert to Pyarrow using zero-copy
|
|
268
|
+
try:
|
|
269
|
+
df = from_dataframe(df, allow_copy=False)
|
|
270
|
+
# if unable to zero-copy, convert the object to Pyarrow
|
|
271
|
+
# this may be undesirable if the object is large because of memory overhead
|
|
272
|
+
except RuntimeError as e:
|
|
273
|
+
if allow_copy is False:
|
|
274
|
+
raise e
|
|
275
|
+
df = from_dataframe(df, allow_copy=True)
|
|
276
|
+
return df.schema
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@_get_arrow_schema.register
|
|
280
|
+
def _(df: h_databackends.AbstractPandasDataFrame, **kwargs) -> pyarrow.Schema:
|
|
281
|
+
"""pandas to pyarrow using pyarrow-native method.
|
|
282
|
+
Removes the pandas metadata added by Pyarrow for cleaner schema diffs
|
|
283
|
+
"""
|
|
284
|
+
table = pyarrow.Table.from_pandas(df, preserve_index=False)
|
|
285
|
+
return table.schema.remove_metadata()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@_get_arrow_schema.register
|
|
289
|
+
def _(df: h_databackends.AbstractIbisDataFrame, **kwargs) -> pyarrow.Schema:
|
|
290
|
+
"""Convert the Ibis schema to pyarrow Schema. The operation is lazy
|
|
291
|
+
and doesn't require Ibis execution"""
|
|
292
|
+
return df.schema().to_pyarrow()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _spark_to_arrow(type_):
|
|
296
|
+
import pyspark.sql.types as pt
|
|
297
|
+
|
|
298
|
+
_from_pyspark_dtypes = {
|
|
299
|
+
pt.NullType: pyarrow.null(),
|
|
300
|
+
pt.BooleanType: pyarrow.bool_(),
|
|
301
|
+
pt.BinaryType: pyarrow.binary(),
|
|
302
|
+
pt.ByteType: pyarrow.int8(),
|
|
303
|
+
pt.ShortType: pyarrow.int16(),
|
|
304
|
+
pt.IntegerType: pyarrow.int32(),
|
|
305
|
+
pt.LongType: pyarrow.int64(),
|
|
306
|
+
pt.DateType: pyarrow.date64(),
|
|
307
|
+
pt.FloatType: pyarrow.float32(),
|
|
308
|
+
pt.DoubleType: pyarrow.float64(),
|
|
309
|
+
pt.TimestampType: pyarrow.timestamp(unit="ms", tz=None),
|
|
310
|
+
pt.TimestampNTZType: pyarrow.timestamp(unit="ms", tz=None),
|
|
311
|
+
pt.StringType: pyarrow.string(),
|
|
312
|
+
pt.VarcharType: pyarrow.string(), # TODO specify length
|
|
313
|
+
pt.CharType: pyarrow.string(), # TODO specify length
|
|
314
|
+
pt.DayTimeIntervalType: pyarrow.month_day_nano_interval(), # TODO specify unit
|
|
315
|
+
pt.YearMonthIntervalType: pyarrow.month_day_nano_interval(), # TODO specify unit
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if isinstance(type_, pt.DecimalType):
|
|
319
|
+
arrow_type = pyarrow.decimal128(type_.precision, type_.scale)
|
|
320
|
+
elif isinstance(type_, pt.ArrayType):
|
|
321
|
+
arrow_type = pyarrow.array([], type=_spark_to_arrow(type_.elementType))
|
|
322
|
+
elif isinstance(type_, pt.MapType):
|
|
323
|
+
arrow_type = pyarrow.map_(
|
|
324
|
+
_spark_to_arrow(type_.keyType),
|
|
325
|
+
_spark_to_arrow(type_.valueType),
|
|
326
|
+
)
|
|
327
|
+
elif isinstance(type_, pt.StructType):
|
|
328
|
+
arrow_type = pyarrow.struct(
|
|
329
|
+
{field.name: _spark_to_arrow(field.dataType) for field in type_.fields}
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
try:
|
|
333
|
+
arrow_type = _from_pyspark_dtypes[type(type_)]
|
|
334
|
+
except KeyError as e:
|
|
335
|
+
raise NotImplementedError(f"Can't convert {type_} to pyarrow type.") from e
|
|
336
|
+
|
|
337
|
+
return arrow_type
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@_get_arrow_schema.register(h_databackends.AbstractSparkSQLDataFrame)
|
|
341
|
+
def _get_spark_schema(df, **kwargs) -> pyarrow.Schema:
|
|
342
|
+
"""Convert the PySpark schema to pyarrow Schema. The operation is lazy
|
|
343
|
+
and doesn't require PySpark execution"""
|
|
344
|
+
return pyarrow.schema(
|
|
345
|
+
pyarrow.field(
|
|
346
|
+
name=field.name,
|
|
347
|
+
type=_spark_to_arrow(field.dataType),
|
|
348
|
+
nullable=field.nullable,
|
|
349
|
+
metadata=field.metadata,
|
|
350
|
+
)
|
|
351
|
+
for field in df.schema
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# TODO lazy polars schema conversion
|
|
356
|
+
# ongoing polars discussion: https://github.com/pola-rs/polars/issues/15600
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_dataframe_schema(df: h_databackends.DATAFRAME_TYPES, node: HamiltonNode) -> pyarrow.Schema:
|
|
360
|
+
"""Get pyarrow schema of a node result and store node metadata on the pyarrow schema."""
|
|
361
|
+
schema = _get_arrow_schema(df)
|
|
362
|
+
metadata = dict(
|
|
363
|
+
name=str(node.name),
|
|
364
|
+
documentation=str(node.documentation),
|
|
365
|
+
version=str(node.version),
|
|
366
|
+
)
|
|
367
|
+
return schema.with_metadata(metadata)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def load_schema(path: str | Path) -> pyarrow.Schema:
|
|
371
|
+
"""Load pyarrow schema from disk using IPC deserialization"""
|
|
372
|
+
return pyarrow.ipc.read_schema(path)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def save_schema(path: str | Path, schema: pyarrow.Schema) -> None:
|
|
376
|
+
"""Save pyarrow schema to disk using IPC serialization"""
|
|
377
|
+
Path(path).write_bytes(schema.serialize())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class SchemaValidator(NodeExecutionHook, GraphExecutionHook):
|
|
381
|
+
"""Collect dataframe and columns schemas at runtime. Can also conduct runtime checks against schemas"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
schema_dir: str = "./schemas",
|
|
386
|
+
check: bool = True,
|
|
387
|
+
must_exist: bool = False,
|
|
388
|
+
importance: Literal["warn", "fail"] = "warn",
|
|
389
|
+
check_schema_metadata: bool = False,
|
|
390
|
+
check_field_metadata: bool = False,
|
|
391
|
+
):
|
|
392
|
+
"""
|
|
393
|
+
:param schema_dir: Directory where schema files will be stored.
|
|
394
|
+
:param check: If True, conduct schema validation
|
|
395
|
+
Else generate schema files, potentially overwriting stored schemas.
|
|
396
|
+
:param must_exist: If True when check=True, raise FileNotFoundError for missing schema files
|
|
397
|
+
Else generate the missing schema files.
|
|
398
|
+
:param importance: How to handle unequal schemas when check=True
|
|
399
|
+
"warn": log a warning with the schema diff
|
|
400
|
+
"fail": raise an exception with the schema diff
|
|
401
|
+
"""
|
|
402
|
+
self.schemas: dict[str, pyarrow.Schema] = {}
|
|
403
|
+
self.reference_schemas: dict[str, pyarrow.Schema] = {}
|
|
404
|
+
self.schema_diffs: dict = {}
|
|
405
|
+
self.schema_dir = schema_dir
|
|
406
|
+
self.check = check
|
|
407
|
+
self.must_exist = must_exist
|
|
408
|
+
self.importance = importance
|
|
409
|
+
self.check_schema_metadata = check_schema_metadata
|
|
410
|
+
self.check_field_metadata = check_field_metadata
|
|
411
|
+
self.h_graph: HamiltonGraph = None
|
|
412
|
+
|
|
413
|
+
# create the directory where schemas will be stored
|
|
414
|
+
Path(schema_dir).mkdir(parents=True, exist_ok=True)
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def json_schemas(self) -> dict[str, dict]:
|
|
418
|
+
"""Return schemas collected during the run"""
|
|
419
|
+
return {
|
|
420
|
+
node_name: pyarrow_schema_to_json(schema) for node_name, schema in self.schemas.items()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
# TODO support nodes returning columns by writing them as single column dataframe
|
|
424
|
+
def get_schema_path(self, node_name: str) -> Path:
|
|
425
|
+
"""Generate schema filepath based on node name.
|
|
426
|
+
|
|
427
|
+
The `.schema` extension is arbitrary. Another common choice is `.arrow`
|
|
428
|
+
but it wouldn't communicate that the file contains only schema metadata.
|
|
429
|
+
The serialization format is IPC by default (see `.save_schema()`).
|
|
430
|
+
"""
|
|
431
|
+
return Path(self.schema_dir, node_name).with_suffix(".schema")
|
|
432
|
+
|
|
433
|
+
def run_before_graph_execution(
|
|
434
|
+
self, *, graph: HamiltonGraph, inputs: dict[str, Any], overrides: dict[str, Any], **kwargs
|
|
435
|
+
):
|
|
436
|
+
"""Store schemas of inputs and overrides nodes that are tables or columns."""
|
|
437
|
+
self.h_graph = graph
|
|
438
|
+
for node_sets in [inputs, overrides]:
|
|
439
|
+
if node_sets is None:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
for node_name, node_value in node_sets.items():
|
|
443
|
+
self.run_after_node_execution(node_name=node_name, result=node_value)
|
|
444
|
+
|
|
445
|
+
def run_after_node_execution(self, *, node_name: str, result: Any, **kwargs):
|
|
446
|
+
"""Store schema of executed node if table or column type."""
|
|
447
|
+
if not isinstance(result, h_databackends.DATAFRAME_TYPES):
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
# generate the schema from the HamiltonNode and node value
|
|
451
|
+
node = self.h_graph[node_name]
|
|
452
|
+
schema = get_dataframe_schema(df=result, node=node)
|
|
453
|
+
self.schemas[node_name] = schema
|
|
454
|
+
|
|
455
|
+
schema_path = self.get_schema_path(node_name)
|
|
456
|
+
|
|
457
|
+
# behavior 1: only save schema
|
|
458
|
+
if self.check is False:
|
|
459
|
+
save_schema(self.get_schema_path(node_name), schema)
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# behavior 2: handle missing reference schema while validating
|
|
463
|
+
if not schema_path.exists():
|
|
464
|
+
if self.must_exist:
|
|
465
|
+
raise FileNotFoundError(
|
|
466
|
+
f"{schema_path} not found. Set `check=False` or `must_exist=False` to create it."
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
save_schema(self.get_schema_path(node_name), schema)
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
# behavior 3: validate current schema with reference schema
|
|
473
|
+
reference_schema = load_schema(self.get_schema_path(node_name))
|
|
474
|
+
schema_diff = diff_schemas(
|
|
475
|
+
current_schema=schema,
|
|
476
|
+
reference_schema=reference_schema,
|
|
477
|
+
check_schema_metadata=self.check_schema_metadata,
|
|
478
|
+
check_field_metadata=self.check_field_metadata,
|
|
479
|
+
)
|
|
480
|
+
if schema_diff != {}:
|
|
481
|
+
self.schema_diffs[node_name] = schema_diff
|
|
482
|
+
readable_diff = json.dumps({node_name: human_readable_diff(schema_diff)}, indent=2)
|
|
483
|
+
if self.importance == "warn":
|
|
484
|
+
logger.warning(f"Schema diff:\n{readable_diff}\n")
|
|
485
|
+
elif self.importance == "fail":
|
|
486
|
+
raise RuntimeError(readable_diff)
|
|
487
|
+
|
|
488
|
+
def run_after_graph_execution(self, *args, **kwargs):
|
|
489
|
+
"""Store a human-readable JSON of all current schemas."""
|
|
490
|
+
Path(f"{self.schema_dir}/schemas.json").write_text(json.dumps(self.json_schemas))
|
|
491
|
+
|
|
492
|
+
def run_before_node_execution(self, *args, **kwargs):
|
|
493
|
+
"""Required by subclassing NodeExecutionHook"""
|
|
@@ -0,0 +1,100 @@
|
|
|
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 traceback
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from slack_sdk import WebClient
|
|
22
|
+
|
|
23
|
+
from hamilton.execution.graph_functions import create_input_string
|
|
24
|
+
from hamilton.lifecycle import NodeExecutionHook
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SlackNotifier(NodeExecutionHook):
|
|
28
|
+
"""This is a adapter that sends a message to a slack channel when a node is executed & fails.
|
|
29
|
+
|
|
30
|
+
Note: you need to have slack_sdk installed for this to work.
|
|
31
|
+
If you don't have it installed, you can install it with `pip install slack_sdk`
|
|
32
|
+
(or `pip install sf-hamilton[slack]` -- use quotes if you're using zsh).
|
|
33
|
+
|
|
34
|
+
.. code-block:: python
|
|
35
|
+
|
|
36
|
+
from hamilton.plugins import h_slack
|
|
37
|
+
|
|
38
|
+
dr = (
|
|
39
|
+
driver.Builder()
|
|
40
|
+
.with_config({})
|
|
41
|
+
.with_modules(some_modules)
|
|
42
|
+
.with_adapters(h_slack.SlackNotifier(api_key="YOUR_API_KEY", channel="YOUR_CHANNEL"))
|
|
43
|
+
.build()
|
|
44
|
+
)
|
|
45
|
+
# and then when you call .execute() or .materialize() you'll get a message in your slack channel!
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, api_key: str, channel: str, **kwargs):
|
|
50
|
+
"""Constructor.
|
|
51
|
+
|
|
52
|
+
:param api_key: API key to use for sending messages.
|
|
53
|
+
:param channel: Channel to send messages to.
|
|
54
|
+
"""
|
|
55
|
+
self.slack_client = WebClient(api_key)
|
|
56
|
+
self.channel = channel
|
|
57
|
+
self.kwargs = kwargs
|
|
58
|
+
|
|
59
|
+
def _send_message(self, message: str):
|
|
60
|
+
"""Sends a message to the slack channel."""
|
|
61
|
+
if self.slack_client is not None:
|
|
62
|
+
self.slack_client.chat_postMessage(channel=self.channel, text=message)
|
|
63
|
+
|
|
64
|
+
def run_before_node_execution(
|
|
65
|
+
self,
|
|
66
|
+
node_name: str,
|
|
67
|
+
node_tags: dict[str, Any],
|
|
68
|
+
node_kwargs: dict[str, Any],
|
|
69
|
+
node_return_type: type,
|
|
70
|
+
**future_kwargs: Any,
|
|
71
|
+
):
|
|
72
|
+
"""Placeholder required to subclass `NodeExecutionMethod`"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def run_after_node_execution(
|
|
76
|
+
self,
|
|
77
|
+
node_name: str,
|
|
78
|
+
node_tags: dict[str, Any],
|
|
79
|
+
node_kwargs: dict[str, Any],
|
|
80
|
+
node_return_type: type,
|
|
81
|
+
result: Any,
|
|
82
|
+
error: Exception | None,
|
|
83
|
+
success: bool,
|
|
84
|
+
task_id: str | None,
|
|
85
|
+
run_id: str,
|
|
86
|
+
**future_kwargs: Any,
|
|
87
|
+
):
|
|
88
|
+
"""Sends a message to the slack channel after a node is executed."""
|
|
89
|
+
if error:
|
|
90
|
+
message = (
|
|
91
|
+
f"*Error Executing Node: `{node_name}`*\n"
|
|
92
|
+
f"> *Run ID:* {run_id}\n"
|
|
93
|
+
f"> *Task ID:* {task_id}\n"
|
|
94
|
+
f"> *Error:* {str(error)}\n"
|
|
95
|
+
f"> *Stack Trace:*\n> ```\n{''.join(traceback.format_exception(type(error), error, error.__traceback__))}```\n"
|
|
96
|
+
f"> *Node Tags:* `{node_tags}`\n"
|
|
97
|
+
f"> *Node Kwargs:* ```{create_input_string(node_kwargs)}```\n"
|
|
98
|
+
f"> *Return Type:* `{node_return_type}`\n"
|
|
99
|
+
)
|
|
100
|
+
self._send_message(message)
|