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,560 @@
|
|
|
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 numbers
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
from hamilton.data_quality import base
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DataInRangeValidatorPandasSeries(base.BaseDefaultValidator):
|
|
32
|
+
def __init__(self, range: tuple[float, float], importance: str):
|
|
33
|
+
"""Data validator that tells if data is in a range. This applies to primitives (ints, floats).
|
|
34
|
+
|
|
35
|
+
:param range: Inclusive range of parameters
|
|
36
|
+
"""
|
|
37
|
+
super(DataInRangeValidatorPandasSeries, self).__init__(importance=importance)
|
|
38
|
+
self.range = range
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def arg(cls) -> str:
|
|
42
|
+
return "range"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
46
|
+
return issubclass(datatype, pd.Series) # TODO -- handle dataframes?
|
|
47
|
+
|
|
48
|
+
def description(self) -> str:
|
|
49
|
+
return f"Validates that the datapoint falls within the range ({self.range[0]}, {self.range[1]})"
|
|
50
|
+
|
|
51
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
52
|
+
min_, max_ = self.range
|
|
53
|
+
between = data.between(min_, max_, inclusive="both")
|
|
54
|
+
counts = between.value_counts().to_dict()
|
|
55
|
+
in_range = counts.get(True, 0)
|
|
56
|
+
out_range = counts.get(False, 0)
|
|
57
|
+
passes = out_range == 0
|
|
58
|
+
message = (
|
|
59
|
+
f"Series contains {in_range} values in range ({min_},{max_}), and {out_range} outside."
|
|
60
|
+
)
|
|
61
|
+
return base.ValidationResult(
|
|
62
|
+
passes=passes,
|
|
63
|
+
message=message,
|
|
64
|
+
diagnostics={
|
|
65
|
+
"range": self.range,
|
|
66
|
+
"in_range": in_range,
|
|
67
|
+
"out_range": out_range,
|
|
68
|
+
"data_size": len(data),
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DataInValuesValidatorPandasSeries(base.BaseDefaultValidator):
|
|
74
|
+
def __init__(self, values_in: Iterable[Any], importance: str):
|
|
75
|
+
"""Data validator that tells if data is in a set of specified values within a pandas series.
|
|
76
|
+
|
|
77
|
+
Note: we will ignore empty/NA values here. If you do not want empty values, use the
|
|
78
|
+
MaxFractionNansValidatorPandasSeries or the AllowNaNsValidatorPandasSeries validator.
|
|
79
|
+
|
|
80
|
+
:param values_in: list of valid values
|
|
81
|
+
"""
|
|
82
|
+
super(DataInValuesValidatorPandasSeries, self).__init__(importance=importance)
|
|
83
|
+
self.values = frozenset(values_in)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def arg(cls) -> str:
|
|
87
|
+
return "values_in"
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
91
|
+
return issubclass(datatype, pd.Series) # TODO -- handle dataframes?
|
|
92
|
+
|
|
93
|
+
def description(self) -> str:
|
|
94
|
+
return f"Validates that all data points are from a fixed set of values: ({self.values}), ignoring NA values."
|
|
95
|
+
|
|
96
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
97
|
+
na_values = data.isna()
|
|
98
|
+
valid_values = data.isin(self.values)
|
|
99
|
+
final_valid_values = valid_values | na_values # data is in valid values, or is NA
|
|
100
|
+
counts = final_valid_values.value_counts().to_dict()
|
|
101
|
+
correct_values_count = counts.get(True, 0)
|
|
102
|
+
incorrect_values_count = counts.get(False, 0)
|
|
103
|
+
incorrect_values = data[~valid_values].value_counts().to_dict()
|
|
104
|
+
passes = incorrect_values_count == 0
|
|
105
|
+
message = (
|
|
106
|
+
f"Series contains {correct_values_count} correct values,"
|
|
107
|
+
f" and {incorrect_values_count} incorrect values."
|
|
108
|
+
)
|
|
109
|
+
if not passes:
|
|
110
|
+
message += f" Valid values are: {self.values}."
|
|
111
|
+
return base.ValidationResult(
|
|
112
|
+
passes=passes,
|
|
113
|
+
message=message,
|
|
114
|
+
diagnostics={
|
|
115
|
+
"values": self.values,
|
|
116
|
+
"correct_values_count": correct_values_count,
|
|
117
|
+
"incorrect_values_count": incorrect_values_count,
|
|
118
|
+
"incorrect_values": incorrect_values,
|
|
119
|
+
"data_size": len(data),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DataInRangeValidatorPrimitives(base.BaseDefaultValidator):
|
|
125
|
+
def __init__(self, range: tuple[numbers.Real, numbers.Real], importance: str):
|
|
126
|
+
"""Data validator that tells if data is in a range. This applies to primitives (ints, floats).
|
|
127
|
+
|
|
128
|
+
:param range: Inclusive range of parameters
|
|
129
|
+
"""
|
|
130
|
+
super(DataInRangeValidatorPrimitives, self).__init__(importance=importance)
|
|
131
|
+
self.range = range
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
135
|
+
return issubclass(datatype, numbers.Real)
|
|
136
|
+
|
|
137
|
+
def description(self) -> str:
|
|
138
|
+
return f"Validates that the datapoint falls within the range ({self.range[0]}, {self.range[1]})"
|
|
139
|
+
|
|
140
|
+
def validate(self, data: numbers.Real) -> base.ValidationResult:
|
|
141
|
+
min_, max_ = self.range
|
|
142
|
+
if hasattr(data, "dask"):
|
|
143
|
+
data = data.compute()
|
|
144
|
+
passes = min_ <= data <= max_
|
|
145
|
+
if passes:
|
|
146
|
+
message = f"Data point {data} falls within acceptable range: ({min_}, {max_})"
|
|
147
|
+
else:
|
|
148
|
+
message = f"Data point {data} does not fall within acceptable range: ({min_}, {max_})"
|
|
149
|
+
return base.ValidationResult(
|
|
150
|
+
passes=passes,
|
|
151
|
+
message=message,
|
|
152
|
+
diagnostics={"range": self.range, "value": data},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def arg(cls) -> str:
|
|
157
|
+
return "range"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DataInValuesValidatorPrimitives(base.BaseDefaultValidator):
|
|
161
|
+
def __init__(self, values_in: Iterable[Any], importance: str):
|
|
162
|
+
"""Data validator that tells if python primitive type data is in a set of specified values.
|
|
163
|
+
|
|
164
|
+
:param values_in: list of valid values
|
|
165
|
+
"""
|
|
166
|
+
super(DataInValuesValidatorPrimitives, self).__init__(importance=importance)
|
|
167
|
+
self.values = frozenset(values_in)
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def arg(cls) -> str:
|
|
171
|
+
return "values_in"
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
175
|
+
return issubclass(datatype, numbers.Real) or issubclass(
|
|
176
|
+
datatype, str
|
|
177
|
+
) # TODO support list, dict and typing.* variants
|
|
178
|
+
|
|
179
|
+
def description(self) -> str:
|
|
180
|
+
return f"Validates that python values are from a fixed set of values: ({self.values})."
|
|
181
|
+
|
|
182
|
+
def validate(self, data: numbers.Real | str) -> base.ValidationResult:
|
|
183
|
+
if hasattr(data, "dask"):
|
|
184
|
+
data = data.compute()
|
|
185
|
+
is_valid_value = data in self.values
|
|
186
|
+
message = f"Primitive python value was valid is {is_valid_value}."
|
|
187
|
+
if not is_valid_value:
|
|
188
|
+
message += f" Correct possible values are {self.values}."
|
|
189
|
+
return base.ValidationResult(
|
|
190
|
+
passes=is_valid_value,
|
|
191
|
+
message=message,
|
|
192
|
+
diagnostics={
|
|
193
|
+
"values": self.values,
|
|
194
|
+
"was_correct": is_valid_value,
|
|
195
|
+
"incorrect_value": None if is_valid_value else data,
|
|
196
|
+
"data_size": 1,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class MaxFractionNansValidatorPandasSeries(base.BaseDefaultValidator):
|
|
202
|
+
def __init__(self, max_fraction_nans: float, importance: str):
|
|
203
|
+
super(MaxFractionNansValidatorPandasSeries, self).__init__(importance=importance)
|
|
204
|
+
MaxFractionNansValidatorPandasSeries._validate_max_fraction_nans(max_fraction_nans)
|
|
205
|
+
self.max_fraction_nans = max_fraction_nans
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _to_percent(fraction: float):
|
|
209
|
+
return "{0:.2%}".format(fraction)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
213
|
+
return issubclass(datatype, pd.Series)
|
|
214
|
+
|
|
215
|
+
def description(self) -> str:
|
|
216
|
+
return f"Validates that no more than {MaxFractionNansValidatorPandasSeries._to_percent(self.max_fraction_nans)} of the data is Nan."
|
|
217
|
+
|
|
218
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
219
|
+
total_length = len(data)
|
|
220
|
+
total_na = data.isna().sum()
|
|
221
|
+
fraction_na = total_na / total_length if total_length > 0 else 0
|
|
222
|
+
passes = fraction_na <= self.max_fraction_nans
|
|
223
|
+
return base.ValidationResult(
|
|
224
|
+
passes=passes,
|
|
225
|
+
message=f"Out of {total_length} items in the series, {total_na} of them are Nan, "
|
|
226
|
+
f"representing: {MaxFractionNansValidatorPandasSeries._to_percent(fraction_na)}. "
|
|
227
|
+
f"Max allowable Nans is: {MaxFractionNansValidatorPandasSeries._to_percent(self.max_fraction_nans)},"
|
|
228
|
+
f" so this {'passes' if passes else 'does not pass'}.",
|
|
229
|
+
diagnostics={
|
|
230
|
+
"total_nan": total_na,
|
|
231
|
+
"total_length": total_length,
|
|
232
|
+
"fraction_na": fraction_na,
|
|
233
|
+
"max_fraction_na": self.max_fraction_nans,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def arg(cls) -> str:
|
|
239
|
+
return "max_fraction_nans"
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _validate_max_fraction_nans(max_fraction_nans: float):
|
|
243
|
+
if not (0 <= max_fraction_nans <= 1):
|
|
244
|
+
raise ValueError("Maximum fraction allowed to be nan must be in range [0,1]")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class AllowNaNsValidatorPandasSeries(MaxFractionNansValidatorPandasSeries):
|
|
248
|
+
def __init__(self, allow_nans: bool, importance: str):
|
|
249
|
+
if allow_nans:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"Only allowed to block Nans with this validator."
|
|
252
|
+
f"Otherwise leave blank or specify the percentage of Nans using {MaxFractionNansValidatorPandasSeries.name()}"
|
|
253
|
+
)
|
|
254
|
+
super(AllowNaNsValidatorPandasSeries, self).__init__(
|
|
255
|
+
max_fraction_nans=0 if not allow_nans else 1.0, importance=importance
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def arg(cls) -> str:
|
|
260
|
+
return "allow_nans"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class DataTypeValidatorPandasSeries(base.BaseDefaultValidator):
|
|
264
|
+
def __init__(self, data_type: type[type], importance: str):
|
|
265
|
+
"""Constructor
|
|
266
|
+
|
|
267
|
+
:param data_type: the numpy data type to expect.
|
|
268
|
+
"""
|
|
269
|
+
super(DataTypeValidatorPandasSeries, self).__init__(importance=importance)
|
|
270
|
+
DataTypeValidatorPandasSeries.datatype = data_type
|
|
271
|
+
self.datatype = data_type
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
275
|
+
return issubclass(datatype, pd.Series)
|
|
276
|
+
|
|
277
|
+
def description(self) -> str:
|
|
278
|
+
return f"Validates that the datatype of the pandas series is a subclass of: {self.datatype}"
|
|
279
|
+
|
|
280
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
281
|
+
dtype = data.dtype
|
|
282
|
+
if hasattr(dtype, "type"):
|
|
283
|
+
dtype = dtype.type
|
|
284
|
+
passes = np.issubdtype(dtype, self.datatype)
|
|
285
|
+
return base.ValidationResult(
|
|
286
|
+
passes=passes,
|
|
287
|
+
message=f"Requires subclass of datatype: {self.datatype}. Got datatype: {dtype}. This {'is' if passes else 'is not'} a match.",
|
|
288
|
+
diagnostics={"required_dtype": self.datatype, "actual_dtype": dtype},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def arg(cls) -> str:
|
|
293
|
+
return "data_type"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class DataTypeValidatorPrimitives(base.BaseDefaultValidator):
|
|
297
|
+
def __init__(self, data_type: type[type], importance: str):
|
|
298
|
+
"""Constructor
|
|
299
|
+
|
|
300
|
+
:param data_type: the python data type to expect.
|
|
301
|
+
"""
|
|
302
|
+
super(DataTypeValidatorPrimitives, self).__init__(importance=importance)
|
|
303
|
+
DataTypeValidatorPrimitives.datatype = data_type
|
|
304
|
+
self.datatype = data_type
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
308
|
+
return issubclass(datatype, numbers.Real) or datatype in (str, bool)
|
|
309
|
+
|
|
310
|
+
def description(self) -> str:
|
|
311
|
+
return f"Validates that the datatype of the pandas series is a subclass of: {self.datatype}"
|
|
312
|
+
|
|
313
|
+
def validate(
|
|
314
|
+
self, data: numbers.Real | str | bool | int | float | list | dict
|
|
315
|
+
) -> base.ValidationResult:
|
|
316
|
+
if hasattr(data, "dask"):
|
|
317
|
+
data = data.compute()
|
|
318
|
+
passes = isinstance(data, self.datatype)
|
|
319
|
+
return base.ValidationResult(
|
|
320
|
+
passes=passes,
|
|
321
|
+
message=f"Requires data type: {self.datatype}. "
|
|
322
|
+
f"Got data type: {type(data)}. This {'is' if passes else 'is not'} a match.",
|
|
323
|
+
diagnostics={
|
|
324
|
+
"required_data_type": self.datatype,
|
|
325
|
+
"actual_data_type": type(data),
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def arg(cls) -> str:
|
|
331
|
+
return "data_type"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class MaxStandardDevValidatorPandasSeries(base.BaseDefaultValidator):
|
|
335
|
+
def __init__(self, max_standard_dev: float, importance: str):
|
|
336
|
+
super(MaxStandardDevValidatorPandasSeries, self).__init__(importance)
|
|
337
|
+
self.max_standard_dev = max_standard_dev
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
341
|
+
return issubclass(datatype, pd.Series)
|
|
342
|
+
|
|
343
|
+
def description(self) -> str:
|
|
344
|
+
return f"Validates that the standard deviation of a pandas series is no greater than : {self.max_standard_dev}"
|
|
345
|
+
|
|
346
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
347
|
+
standard_dev = data.std()
|
|
348
|
+
passes = standard_dev <= self.max_standard_dev
|
|
349
|
+
return base.ValidationResult(
|
|
350
|
+
passes=passes,
|
|
351
|
+
message=f"Max allowable standard dev is: {self.max_standard_dev}. "
|
|
352
|
+
f"Dataset stddev is : {standard_dev}. "
|
|
353
|
+
f"This {'passes' if passes else 'does not pass'}.",
|
|
354
|
+
diagnostics={
|
|
355
|
+
"standard_dev": standard_dev,
|
|
356
|
+
"max_standard_dev": self.max_standard_dev,
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def arg(cls) -> str:
|
|
362
|
+
return "max_standard_dev"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class MeanInRangeValidatorPandasSeries(base.BaseDefaultValidator):
|
|
366
|
+
def __init__(self, mean_in_range: tuple[float, float], importance: str):
|
|
367
|
+
super(MeanInRangeValidatorPandasSeries, self).__init__(importance)
|
|
368
|
+
self.mean_in_range = mean_in_range
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
372
|
+
return issubclass(datatype, pd.Series)
|
|
373
|
+
|
|
374
|
+
def description(self) -> str:
|
|
375
|
+
return f"Validates that a pandas series has mean in range [{self.mean_in_range[0]}, {self.mean_in_range[1]}]"
|
|
376
|
+
|
|
377
|
+
def validate(self, data: pd.Series) -> base.ValidationResult:
|
|
378
|
+
dataset_mean = data.mean()
|
|
379
|
+
min_, max_ = self.mean_in_range
|
|
380
|
+
passes = min_ <= dataset_mean <= max_
|
|
381
|
+
return base.ValidationResult(
|
|
382
|
+
passes=passes,
|
|
383
|
+
message=f"Dataset has mean: {dataset_mean}. This {'is ' if passes else 'is not '} "
|
|
384
|
+
f"in the required range: [{self.mean_in_range[0]}, {self.mean_in_range[1]}].",
|
|
385
|
+
diagnostics={
|
|
386
|
+
"dataset_mean": dataset_mean,
|
|
387
|
+
"mean_in_range": self.mean_in_range,
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def arg(cls) -> str:
|
|
393
|
+
return "mean_in_range"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class AllowNoneValidator(base.BaseDefaultValidator):
|
|
397
|
+
def __init__(self, allow_none: bool, importance: str):
|
|
398
|
+
super(AllowNoneValidator, self).__init__(importance)
|
|
399
|
+
self.allow_none = allow_none
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
def description(self) -> str:
|
|
406
|
+
if self.allow_none:
|
|
407
|
+
return "No-op validator."
|
|
408
|
+
return "Validates that an output ;is not None"
|
|
409
|
+
|
|
410
|
+
def validate(self, data: Any) -> base.ValidationResult:
|
|
411
|
+
passes = True
|
|
412
|
+
if not self.allow_none:
|
|
413
|
+
if data is None:
|
|
414
|
+
passes = False
|
|
415
|
+
return base.ValidationResult(
|
|
416
|
+
passes=passes,
|
|
417
|
+
message=(
|
|
418
|
+
f"Data is not allowed to be None, got {data}" if not passes else "Data is not None"
|
|
419
|
+
),
|
|
420
|
+
diagnostics={}, # Nothing necessary here...
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
@classmethod
|
|
424
|
+
def arg(cls) -> str:
|
|
425
|
+
return "allow_none"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class StrContainsValidator(base.BaseDefaultValidator):
|
|
429
|
+
def __init__(self, contains: str | list[str], importance: str):
|
|
430
|
+
super(StrContainsValidator, self).__init__(importance)
|
|
431
|
+
if isinstance(contains, str):
|
|
432
|
+
self.contains = [contains]
|
|
433
|
+
else:
|
|
434
|
+
self.contains = contains
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
438
|
+
return datatype == str
|
|
439
|
+
|
|
440
|
+
def description(self) -> str:
|
|
441
|
+
return f"Validates that a string contains [{self.contains}] within it."
|
|
442
|
+
|
|
443
|
+
def validate(self, data: str) -> base.ValidationResult:
|
|
444
|
+
passes = all([c in data for c in self.contains])
|
|
445
|
+
return base.ValidationResult(
|
|
446
|
+
passes=passes,
|
|
447
|
+
message=(f"String did not contain {self.contains}" if not passes else "All good."),
|
|
448
|
+
diagnostics=(
|
|
449
|
+
{"contains": self.contains, "data": data if len(data) < 100 else data[:100]}
|
|
450
|
+
if not passes
|
|
451
|
+
else {}
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
@classmethod
|
|
456
|
+
def arg(cls) -> str:
|
|
457
|
+
return "contains"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class StrDoesNotContainValidator(base.BaseDefaultValidator):
|
|
461
|
+
def __init__(self, does_not_contain: str | list[str], importance: str):
|
|
462
|
+
super(StrDoesNotContainValidator, self).__init__(importance)
|
|
463
|
+
if isinstance(does_not_contain, str):
|
|
464
|
+
self.does_not_contain = [does_not_contain]
|
|
465
|
+
else:
|
|
466
|
+
self.does_not_contain = does_not_contain
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
470
|
+
return datatype == str
|
|
471
|
+
|
|
472
|
+
def description(self) -> str:
|
|
473
|
+
return f"Validates that a string does not contain [{self.does_not_contain}] within it."
|
|
474
|
+
|
|
475
|
+
def validate(self, data: str) -> base.ValidationResult:
|
|
476
|
+
passes = all([c not in data for c in self.does_not_contain])
|
|
477
|
+
return base.ValidationResult(
|
|
478
|
+
passes=passes,
|
|
479
|
+
message=(f"String did contain {self.does_not_contain}" if not passes else "All good."),
|
|
480
|
+
diagnostics=(
|
|
481
|
+
{
|
|
482
|
+
"does_not_contain": self.does_not_contain,
|
|
483
|
+
"data": data if len(data) < 100 else data[:100],
|
|
484
|
+
}
|
|
485
|
+
if not passes
|
|
486
|
+
else {}
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
@classmethod
|
|
491
|
+
def arg(cls) -> str:
|
|
492
|
+
return "does_not_contain"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
AVAILABLE_DEFAULT_VALIDATORS = [
|
|
496
|
+
AllowNaNsValidatorPandasSeries,
|
|
497
|
+
DataInRangeValidatorPandasSeries,
|
|
498
|
+
DataInRangeValidatorPrimitives,
|
|
499
|
+
DataInValuesValidatorPandasSeries,
|
|
500
|
+
DataInValuesValidatorPrimitives,
|
|
501
|
+
DataTypeValidatorPandasSeries,
|
|
502
|
+
DataTypeValidatorPrimitives,
|
|
503
|
+
MaxFractionNansValidatorPandasSeries,
|
|
504
|
+
MaxStandardDevValidatorPandasSeries,
|
|
505
|
+
MeanInRangeValidatorPandasSeries,
|
|
506
|
+
AllowNoneValidator,
|
|
507
|
+
StrContainsValidator,
|
|
508
|
+
StrDoesNotContainValidator,
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _append_pandera_to_default_validators():
|
|
513
|
+
"""Utility method to append pandera validators as needed"""
|
|
514
|
+
try:
|
|
515
|
+
import pandera # noqa: F401
|
|
516
|
+
except ModuleNotFoundError:
|
|
517
|
+
logger.info(
|
|
518
|
+
"Cannot import pandera from pandera_validators. Run pip install sf-hamilton[pandera] if needed."
|
|
519
|
+
)
|
|
520
|
+
return
|
|
521
|
+
from hamilton.data_quality import pandera_validators
|
|
522
|
+
|
|
523
|
+
AVAILABLE_DEFAULT_VALIDATORS.extend(pandera_validators.PANDERA_VALIDATORS)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
_append_pandera_to_default_validators()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def resolve_default_validators(
|
|
530
|
+
output_type: type[type],
|
|
531
|
+
importance: str,
|
|
532
|
+
available_validators: list[type[base.BaseDefaultValidator]] = None,
|
|
533
|
+
**default_validator_kwargs,
|
|
534
|
+
) -> list[base.BaseDefaultValidator]:
|
|
535
|
+
"""Resolves default validators given a set pof parameters and the type to which they apply.
|
|
536
|
+
Note that each (kwarg, type) combination should map to a validator
|
|
537
|
+
:param importance: importance level of the validator to instantiate
|
|
538
|
+
:param output_type: The type to which the validator should apply
|
|
539
|
+
:param available_validators: The available validators to choose from
|
|
540
|
+
:param default_validator_kwargs: Kwargs to use
|
|
541
|
+
:return: A list of validators to use
|
|
542
|
+
"""
|
|
543
|
+
if available_validators is None:
|
|
544
|
+
available_validators = AVAILABLE_DEFAULT_VALIDATORS
|
|
545
|
+
validators = []
|
|
546
|
+
for key in default_validator_kwargs.keys():
|
|
547
|
+
for validator_cls in available_validators:
|
|
548
|
+
if key == validator_cls.arg() and validator_cls.applies_to(output_type):
|
|
549
|
+
validators.append(
|
|
550
|
+
validator_cls(**{key: default_validator_kwargs[key], "importance": importance})
|
|
551
|
+
)
|
|
552
|
+
break
|
|
553
|
+
else:
|
|
554
|
+
raise ValueError(
|
|
555
|
+
f"No registered subclass of BaseDefaultValidator is available "
|
|
556
|
+
f"for arg: {key} and type {output_type}. This either means (a) this arg-type "
|
|
557
|
+
f"contribution isn't supported or (b) this has not been added yet (but should be). "
|
|
558
|
+
f"In the case of (b), we welcome contributions. Get started at github.com/dagworks-inc/hamilton."
|
|
559
|
+
)
|
|
560
|
+
return validators
|
|
@@ -0,0 +1,121 @@
|
|
|
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 typing import Any
|
|
19
|
+
|
|
20
|
+
import pandera as pa
|
|
21
|
+
|
|
22
|
+
from hamilton import registry
|
|
23
|
+
from hamilton.data_quality import base
|
|
24
|
+
from hamilton.htypes import custom_subclass_check
|
|
25
|
+
|
|
26
|
+
pandera_supported_extensions = frozenset(["pandas", "dask", "pyspark_pandas", "polars"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PanderaDataFrameValidator(base.BaseDefaultValidator):
|
|
30
|
+
"""Pandera schema validator for dataframes"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, schema: pa.DataFrameSchema, importance: str):
|
|
33
|
+
super(PanderaDataFrameValidator, self).__init__(importance)
|
|
34
|
+
self.schema = schema
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
38
|
+
for extension_name in pandera_supported_extensions:
|
|
39
|
+
if extension_name in registry.DF_TYPE_AND_COLUMN_TYPES:
|
|
40
|
+
df_type = registry.DF_TYPE_AND_COLUMN_TYPES[extension_name][registry.DATAFRAME_TYPE]
|
|
41
|
+
result = custom_subclass_check(datatype, df_type)
|
|
42
|
+
if result:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def description(self) -> str:
|
|
47
|
+
return "Validates that the returned dataframe matches the pander"
|
|
48
|
+
|
|
49
|
+
def validate(self, data: Any) -> base.ValidationResult:
|
|
50
|
+
try:
|
|
51
|
+
result = self.schema.validate(data, lazy=True, inplace=True)
|
|
52
|
+
if hasattr(result, "dask"):
|
|
53
|
+
result.compute()
|
|
54
|
+
except pa.errors.SchemaErrors as e:
|
|
55
|
+
return base.ValidationResult(
|
|
56
|
+
passes=False,
|
|
57
|
+
message=str(e),
|
|
58
|
+
diagnostics={"schema_errors": e.schema_errors},
|
|
59
|
+
)
|
|
60
|
+
return base.ValidationResult(
|
|
61
|
+
passes=True,
|
|
62
|
+
message=f"Data passes pandera check for schema {str(self.schema)}",
|
|
63
|
+
# TDOO -- add diagnostics data with serialized the schema
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def arg(cls) -> str:
|
|
68
|
+
return "schema" # TODO -- determine whether we want to allow other schemas
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def name(cls) -> str:
|
|
72
|
+
return "pandera_schema_validator"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PanderaSeriesSchemaValidator(base.BaseDefaultValidator):
|
|
76
|
+
"""Pandera schema validator for series"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, schema: pa.SeriesSchema, importance: str):
|
|
79
|
+
super(PanderaSeriesSchemaValidator, self).__init__(importance)
|
|
80
|
+
self.schema = schema
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def applies_to(cls, datatype: type[type]) -> bool:
|
|
84
|
+
for extension_name in pandera_supported_extensions:
|
|
85
|
+
if extension_name in registry.DF_TYPE_AND_COLUMN_TYPES:
|
|
86
|
+
df_type = registry.DF_TYPE_AND_COLUMN_TYPES[extension_name][registry.COLUMN_TYPE]
|
|
87
|
+
result = custom_subclass_check(datatype, df_type)
|
|
88
|
+
if result:
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def description(self) -> str:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def validate(self, data: Any) -> base.ValidationResult:
|
|
96
|
+
try:
|
|
97
|
+
result = self.schema.validate(data, lazy=True, inplace=True)
|
|
98
|
+
if hasattr(result, "dask"):
|
|
99
|
+
result.compute()
|
|
100
|
+
except pa.errors.SchemaErrors as e:
|
|
101
|
+
return base.ValidationResult(
|
|
102
|
+
passes=False,
|
|
103
|
+
message=str(e),
|
|
104
|
+
diagnostics={"schema_errors": e.schema_errors},
|
|
105
|
+
)
|
|
106
|
+
return base.ValidationResult(
|
|
107
|
+
passes=True,
|
|
108
|
+
message=f"Data passes pandera check for schema {str(self.schema)}",
|
|
109
|
+
# TDOO -- add diagnostics data with serialized the schema
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def arg(cls) -> str:
|
|
114
|
+
return "schema" # TODO -- determine whether we want to allow other schemas
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def name(cls) -> str:
|
|
118
|
+
return "pandera_schema_validator"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
PANDERA_VALIDATORS = [PanderaDataFrameValidator, PanderaSeriesSchemaValidator]
|