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,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)