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,47 @@
|
|
|
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
|
+
try:
|
|
21
|
+
import dask.dataframe as dd
|
|
22
|
+
except ImportError as e:
|
|
23
|
+
raise NotImplementedError("Dask is not installed.") from e
|
|
24
|
+
|
|
25
|
+
from hamilton import registry
|
|
26
|
+
|
|
27
|
+
DATAFRAME_TYPE = dd.DataFrame
|
|
28
|
+
COLUMN_TYPE = dd.Series
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@registry.get_column.register(dd.DataFrame)
|
|
32
|
+
def get_column_dask(df: dd.DataFrame, column_name: str) -> dd.Series:
|
|
33
|
+
return df[column_name]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@registry.fill_with_scalar.register(dd.DataFrame)
|
|
37
|
+
def fill_with_scalar_dask(df: dd.DataFrame, column_name: str, value: Any) -> dd.DataFrame:
|
|
38
|
+
df[column_name] = value
|
|
39
|
+
return df
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_types():
|
|
43
|
+
"""Function to register the types for this extension."""
|
|
44
|
+
registry.register_types("dask", DATAFRAME_TYPE, COLUMN_TYPE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
register_types()
|
|
@@ -0,0 +1,161 @@
|
|
|
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 dataclasses
|
|
19
|
+
from collections.abc import Collection, Iterable, Sequence
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import dlt
|
|
24
|
+
from dlt.common.destination.capabilities import TLoaderFileFormat
|
|
25
|
+
from dlt.common.schema import Schema, TColumnSchema
|
|
26
|
+
|
|
27
|
+
# importing TDestinationReferenceArg fails if Destination isn't imported
|
|
28
|
+
from dlt.extract.resource import DltResource
|
|
29
|
+
|
|
30
|
+
except ImportError as e:
|
|
31
|
+
# raise import error first
|
|
32
|
+
raise ImportError(f"Failed to import the DLT library. {e}") from e
|
|
33
|
+
except Exception as e:
|
|
34
|
+
# raise import error with custom message
|
|
35
|
+
raise ImportError("Failed to import the DLT library.") from e
|
|
36
|
+
|
|
37
|
+
import pandas as pd
|
|
38
|
+
|
|
39
|
+
from hamilton import registry
|
|
40
|
+
from hamilton.io import utils
|
|
41
|
+
from hamilton.io.data_adapters import DataLoader, DataSaver
|
|
42
|
+
|
|
43
|
+
DATAFRAME_TYPES = [Iterable, pd.DataFrame]
|
|
44
|
+
|
|
45
|
+
# TODO add types for other Dataframe libraries
|
|
46
|
+
try:
|
|
47
|
+
import pyarrow as pa
|
|
48
|
+
|
|
49
|
+
DATAFRAME_TYPES.extend([pa.Table, pa.RecordBatch])
|
|
50
|
+
except ModuleNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# convert to tuple to dynamically define type `Union[DATAFRAME_TYPES]`
|
|
54
|
+
DATAFRAME_TYPES = tuple(DATAFRAME_TYPES)
|
|
55
|
+
COLUMN_FRIENDLY_DF_TYPE = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass
|
|
59
|
+
class DltResourceLoader(DataLoader):
|
|
60
|
+
resource: DltResource
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def name(cls) -> str:
|
|
64
|
+
return "dlt"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def applicable_types(cls) -> Collection[type]:
|
|
68
|
+
return [pd.DataFrame]
|
|
69
|
+
|
|
70
|
+
def load_data(self, type_: type) -> tuple[pd.DataFrame, dict[str, Any]]:
|
|
71
|
+
"""Creates a pipeline and conduct `extract` and `normalize` steps.
|
|
72
|
+
Then, "load packages" are read with pandas
|
|
73
|
+
"""
|
|
74
|
+
pipeline = dlt.pipeline(
|
|
75
|
+
pipeline_name="Hamilton-DltResourceLoader", destination="filesystem"
|
|
76
|
+
)
|
|
77
|
+
pipeline.extract(self.resource, loader_file_format="parquet")
|
|
78
|
+
normalize_info = pipeline.normalize()
|
|
79
|
+
|
|
80
|
+
partition_file_paths = []
|
|
81
|
+
package = normalize_info.load_packages[0]
|
|
82
|
+
for job in package.jobs["new_jobs"]:
|
|
83
|
+
if job.job_file_info.table_name == self.resource.name:
|
|
84
|
+
partition_file_paths.append(job.file_path)
|
|
85
|
+
|
|
86
|
+
# TODO use pyarrow directly to support different dataframe libraries
|
|
87
|
+
# ref: https://github.com/dlt-hub/verified-sources/blob/master/sources/filesystem/readers.py
|
|
88
|
+
# ref: https://arrow.apache.org/docs/python/generated/pyarrow.parquet.ParquetDataset.html#pyarrow.parquet.ParquetDataset
|
|
89
|
+
df = pd.concat([pd.read_parquet(f) for f in partition_file_paths], ignore_index=True)
|
|
90
|
+
|
|
91
|
+
# delete the pipeline
|
|
92
|
+
pipeline.drop()
|
|
93
|
+
|
|
94
|
+
metadata = utils.get_dataframe_metadata(df)
|
|
95
|
+
return df, metadata
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# TODO handle behavior with `combine=`, currently only supports materializing a single node
|
|
99
|
+
@dataclasses.dataclass
|
|
100
|
+
class DltDestinationSaver(DataSaver):
|
|
101
|
+
"""Materialize results using a dlt pipeline with the specified destination.
|
|
102
|
+
|
|
103
|
+
In reference to an Extract, Transform, Load (ETL) pipeline, here, the Hamilton
|
|
104
|
+
dataflow is responsible for Transform, and `DltDestination` for Load.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
pipeline: dlt.Pipeline
|
|
108
|
+
table_name: str
|
|
109
|
+
primary_key: str | None = None
|
|
110
|
+
write_disposition: Literal["skip", "append", "replace", "merge"] | None = None
|
|
111
|
+
columns: Sequence[TColumnSchema] | None = None
|
|
112
|
+
schema: Schema | None = None
|
|
113
|
+
loader_file_format: TLoaderFileFormat | None = None
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def name(cls) -> str:
|
|
117
|
+
return "dlt"
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def applicable_types(cls) -> Collection[type]:
|
|
121
|
+
return DATAFRAME_TYPES
|
|
122
|
+
|
|
123
|
+
def _get_kwargs(self) -> dict:
|
|
124
|
+
kwargs = {}
|
|
125
|
+
fields_to_skip = ["pipeline"]
|
|
126
|
+
for field in dataclasses.fields(self):
|
|
127
|
+
field_value = getattr(self, field.name)
|
|
128
|
+
if field.name in fields_to_skip:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if field_value != field.default:
|
|
132
|
+
kwargs[field.name] = field_value
|
|
133
|
+
|
|
134
|
+
return kwargs
|
|
135
|
+
|
|
136
|
+
# TODO get pyarrow table from polars, dask, etc.
|
|
137
|
+
def save_data(self, data) -> dict[str, Any]:
|
|
138
|
+
"""
|
|
139
|
+
ref: https://dlthub.com/docs/dlt-ecosystem/verified-sources/arrow-pandas
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(data, dict):
|
|
142
|
+
raise NotImplementedError(
|
|
143
|
+
"DltDestinationSaver received data of type `dict`."
|
|
144
|
+
"Currently, it doesn't support specifying `combine=base.DictResult()`"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
load_info = self.pipeline.run(data, **self._get_kwargs())
|
|
148
|
+
# follows the pattern of metadata output found in hamilton.io.utils
|
|
149
|
+
return {"dlt_metadata": load_info.asdict()}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def register_data_loaders():
|
|
153
|
+
"""Function to register the data loaders for this extension."""
|
|
154
|
+
for loader in [
|
|
155
|
+
DltDestinationSaver,
|
|
156
|
+
DltResourceLoader,
|
|
157
|
+
]:
|
|
158
|
+
registry.register_adapter(loader)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
register_data_loaders()
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
try:
|
|
21
|
+
import geopandas as gpd
|
|
22
|
+
except ImportError as e:
|
|
23
|
+
raise NotImplementedError("geopandas is not installed.") from e
|
|
24
|
+
|
|
25
|
+
from hamilton import registry
|
|
26
|
+
|
|
27
|
+
DATAFRAME_TYPE = gpd.GeoDataFrame
|
|
28
|
+
COLUMN_TYPE = gpd.GeoSeries
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@registry.get_column.register(gpd.GeoDataFrame)
|
|
32
|
+
def get_column_geopandas(df: gpd.GeoDataFrame, column_name: str) -> gpd.GeoSeries:
|
|
33
|
+
return df[column_name]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@registry.fill_with_scalar.register(gpd.GeoDataFrame)
|
|
37
|
+
def fill_with_scalar_geopandas(
|
|
38
|
+
df: gpd.GeoDataFrame, column_name: str, value: Any
|
|
39
|
+
) -> gpd.GeoDataFrame:
|
|
40
|
+
df[column_name] = value
|
|
41
|
+
return df
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register_types():
|
|
45
|
+
"""Function to register the types for this extension."""
|
|
46
|
+
registry.register_types("geopandas", DATAFRAME_TYPE, COLUMN_TYPE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
register_types()
|
|
@@ -0,0 +1,331 @@
|
|
|
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 typing
|
|
20
|
+
|
|
21
|
+
import dask.array
|
|
22
|
+
import dask.dataframe
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from dask import compute
|
|
26
|
+
from dask.base import tokenize
|
|
27
|
+
from dask.delayed import Delayed, delayed
|
|
28
|
+
from dask.distributed import Client as DaskClient
|
|
29
|
+
|
|
30
|
+
from hamilton import base, htypes, node
|
|
31
|
+
from hamilton.execution import executors
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from dask.dataframe.dask_expr import Scalar as dask_scalar
|
|
37
|
+
except ImportError:
|
|
38
|
+
# this is for older versions of dask
|
|
39
|
+
from dask.dataframe.core import Scalar as dask_scalar
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DaskGraphAdapter(base.HamiltonGraphAdapter):
|
|
43
|
+
"""Class representing what's required to make Hamilton run on Dask.
|
|
44
|
+
|
|
45
|
+
This walks the graph and translates it to run onto `Dask <https://dask.org/>`__.
|
|
46
|
+
|
|
47
|
+
Use `pip install sf-hamilton[dask]` to get the dependencies required to run this.
|
|
48
|
+
|
|
49
|
+
Try this adapter when:
|
|
50
|
+
|
|
51
|
+
1. Dask is a good choice to scale computation when you really can't do things in memory anymore with pandas. \
|
|
52
|
+
For most simple pandas operations, you should not have to do anything to scale! You just need to load in \
|
|
53
|
+
data via dask rather than pandas.
|
|
54
|
+
2. Dask can help scale to larger data sets if running on a cluster -- you'll just have to switch to\
|
|
55
|
+
natively using their object types if that's the case (set use_delayed=False, and compute_at_end=False).
|
|
56
|
+
3. Use this adapter if you want to utilize multiple cores on a single machine, or you want to scale to large \
|
|
57
|
+
data set sizes with a Dask cluster that you can connect to.
|
|
58
|
+
4. The ONLY CAVEAT really is whether you use `delayed` or `dask datatypes` (or both).
|
|
59
|
+
|
|
60
|
+
Please read the following notes about its limitations.
|
|
61
|
+
|
|
62
|
+
Notes on scaling:
|
|
63
|
+
-----------------
|
|
64
|
+
- Multi-core on single machine ✅
|
|
65
|
+
- Distributed computation on a Dask cluster ✅
|
|
66
|
+
- Scales to any size of data supported by Dask ✅; assuming you load it appropriately via Dask loaders.
|
|
67
|
+
- Works best with Pandas 2.0+ and pyarrow backend.
|
|
68
|
+
|
|
69
|
+
Function return object types supported:
|
|
70
|
+
---------------------------------------
|
|
71
|
+
- Works for any python object that can be serialized by the Dask framework. ✅
|
|
72
|
+
|
|
73
|
+
Pandas?
|
|
74
|
+
-------
|
|
75
|
+
Dask implements a good subset of the Pandas API:
|
|
76
|
+
- You might be able to get away with scaling without having to change your code at all!
|
|
77
|
+
- See https://docs.dask.org/en/latest/dataframe-api.html for Pandas supported APIs.
|
|
78
|
+
- If it is not supported by their API, you have to then read up and think about how to structure you hamilton\
|
|
79
|
+
function computation -- https://docs.dask.org/en/latest/dataframe.html
|
|
80
|
+
- if paired with DaskDataFrameResult & use_delayed=False & compute_at_end=False, it will help you produce a \
|
|
81
|
+
dask dataframe as a result that you can then convert back to pandas if you want.
|
|
82
|
+
|
|
83
|
+
Loading Data:
|
|
84
|
+
-------------
|
|
85
|
+
- see https://docs.dask.org/en/latest/best-practices.html#load-data-with-dask.
|
|
86
|
+
- we recommend creating a python module specifically encapsulating functions that help you load data.
|
|
87
|
+
|
|
88
|
+
CAVEATS with use_delayed=True:
|
|
89
|
+
------------------------------
|
|
90
|
+
- If using `use_delayed=True` serialization costs can outweigh the benefits of parallelism, so you should \
|
|
91
|
+
benchmark your code to see if it's worth it.
|
|
92
|
+
- With this adapter & use_delayed=True, it can naively wrap all your functions with `delayed`, which will mean \
|
|
93
|
+
they will be executed and scheduled across the dask workers. This is a good choice if your computation is slow, \
|
|
94
|
+
or Hamilton graph is highly parallelizable.
|
|
95
|
+
|
|
96
|
+
DISCLAIMER -- this class is experimental, so signature changes are a possibility! But we'll aim to be backwards
|
|
97
|
+
compatible where possible.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
dask_client: DaskClient,
|
|
103
|
+
result_builder: base.ResultMixin = None,
|
|
104
|
+
visualize_kwargs: dict = None,
|
|
105
|
+
use_delayed: bool = True,
|
|
106
|
+
compute_at_end: bool = True,
|
|
107
|
+
):
|
|
108
|
+
"""Constructor
|
|
109
|
+
|
|
110
|
+
You have the ability to pass in a ResultMixin object to the constructor to control the return type that gets\
|
|
111
|
+
produced by running on Dask.
|
|
112
|
+
|
|
113
|
+
:param dask_client: the dask client -- we don't do anything with it, but thought that it would be useful\
|
|
114
|
+
to wire through here.
|
|
115
|
+
:param result_builder: The function that will build the result. Optional, defaults to pandas dataframe.
|
|
116
|
+
:param visualize_kwargs: Arguments to visualize the graph using dask's internals.\
|
|
117
|
+
**None**, means no visualization.\
|
|
118
|
+
**Dict**, means visualize -- see https://docs.dask.org/en/latest/api.html?highlight=visualize#dask.visualize\
|
|
119
|
+
for what to pass in.
|
|
120
|
+
:param use_delayed: Default is True for backwards compatibility. Whether to use dask.delayed to wrap every
|
|
121
|
+
function. Note: it is probably not necessary to mix this with using dask objects, e.g. dataframes/series.
|
|
122
|
+
They are by nature lazily computed and operate over the dask data types, so you don't need to wrap them
|
|
123
|
+
with delayed. Use delayed if you want to farm out computation.
|
|
124
|
+
:param compute_at_end: Default is True for backwards compatibility. Whether to compute() at the end.
|
|
125
|
+
That is, should `.compute()` be called in the result builder to quick off computation.
|
|
126
|
+
"""
|
|
127
|
+
self.client = dask_client
|
|
128
|
+
self.result_builder = result_builder or base.PandasDataFrameResult()
|
|
129
|
+
self.visualize_kwargs = visualize_kwargs
|
|
130
|
+
self.use_delayed = use_delayed
|
|
131
|
+
self.compute_at_end = compute_at_end
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def check_input_type(node_type: type, input_value: typing.Any) -> bool:
|
|
135
|
+
# NOTE: the type of dask Delayed is unknown until they are computed
|
|
136
|
+
if isinstance(input_value, Delayed):
|
|
137
|
+
return True
|
|
138
|
+
elif node_type == pd.Series and isinstance(input_value, dask.dataframe.Series):
|
|
139
|
+
return True
|
|
140
|
+
elif node_type == np.array and isinstance(input_value, dask.array.Array):
|
|
141
|
+
return True
|
|
142
|
+
return htypes.check_input_type(node_type, input_value)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def check_node_type_equivalence(node_type: type, input_type: type) -> bool:
|
|
146
|
+
if node_type == dask.array.Array and input_type == pd.Series:
|
|
147
|
+
return True
|
|
148
|
+
elif node_type == dask.dataframe.Series and input_type == pd.Series:
|
|
149
|
+
return True
|
|
150
|
+
return node_type == input_type
|
|
151
|
+
|
|
152
|
+
def execute_node(self, node: node.Node, kwargs: dict[str, typing.Any]) -> typing.Any:
|
|
153
|
+
"""Function that is called as we walk the graph to determine how to execute a hamilton function.
|
|
154
|
+
|
|
155
|
+
:param node: the node from the graph.
|
|
156
|
+
:param kwargs: the arguments that should be passed to it.
|
|
157
|
+
:return: returns a dask delayed object.
|
|
158
|
+
"""
|
|
159
|
+
if not self.use_delayed:
|
|
160
|
+
return node.callable(**kwargs)
|
|
161
|
+
# we want to ensure the name in dask corresponds to the node name, and not the wrapped
|
|
162
|
+
# function name that hamilton might have wrapped it with.
|
|
163
|
+
hash = tokenize(kwargs) # this is what the dask docs recommend.
|
|
164
|
+
name = node.name + hash
|
|
165
|
+
dask_key_name = str(node.name) + "_" + hash
|
|
166
|
+
return delayed(node.callable, name=name)(
|
|
167
|
+
**kwargs,
|
|
168
|
+
dask_key_name=dask_key_name, # this is what shows up in the dask console
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def build_result(self, **outputs: dict[str, typing.Any]) -> typing.Any:
|
|
172
|
+
"""Builds the result and brings it back to this running process.
|
|
173
|
+
|
|
174
|
+
:param outputs: the dictionary of key -> Union[delayed object reference | value]
|
|
175
|
+
:return: The type of object returned by self.result_builder. Note the following behaviors:
|
|
176
|
+
- if you use_delayed=True, then the result will be a delayed object.
|
|
177
|
+
- if you use_delayed=True & computed_at_end=True, then the result will be the return type
|
|
178
|
+
of self.result_builder.
|
|
179
|
+
- if you use_delayed=False & computed_at_end=True, this will only work if the self.result_builder
|
|
180
|
+
returns a dask type, as we will try to compute it.
|
|
181
|
+
- if you use_delayed=False & computed_at_end=False, this will return the result of self.result_builder.
|
|
182
|
+
"""
|
|
183
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
184
|
+
for k, v in outputs.items():
|
|
185
|
+
logger.debug(f"Got column {k}, with type [{type(v)}].")
|
|
186
|
+
if self.use_delayed:
|
|
187
|
+
delayed_result = delayed(self.result_builder.build_result)(**outputs)
|
|
188
|
+
else:
|
|
189
|
+
delayed_result = self.result_builder.build_result(**outputs)
|
|
190
|
+
if self.visualize_kwargs is not None:
|
|
191
|
+
delayed_result.visualize(**self.visualize_kwargs)
|
|
192
|
+
if self.compute_at_end:
|
|
193
|
+
(result,) = compute(delayed_result)
|
|
194
|
+
return result
|
|
195
|
+
else:
|
|
196
|
+
return delayed_result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class DaskDataFrameResult(base.ResultMixin):
|
|
200
|
+
@staticmethod
|
|
201
|
+
def build_result(**outputs: dict[str, typing.Any]) -> typing.Any:
|
|
202
|
+
"""Builds a dask dataframe from the outputs.
|
|
203
|
+
|
|
204
|
+
This has some assumptions:
|
|
205
|
+
1. the order specified in the output will mirror the order of "joins" here.
|
|
206
|
+
2. it tries to massage types into dask types where it can
|
|
207
|
+
3. otherwise it duplicates any "scalars/objects" using the first valid input with an index as the
|
|
208
|
+
template. It assumes a single partition.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def get_output_name(output_name: str, column_name: str) -> str:
|
|
212
|
+
"""Add function prefix to columns.
|
|
213
|
+
Note this means that they stop being valid python identifiers due to the `.` in the string.
|
|
214
|
+
"""
|
|
215
|
+
return f"{output_name}.{column_name}"
|
|
216
|
+
|
|
217
|
+
if len(outputs) == 0:
|
|
218
|
+
raise ValueError("No outputs were specified. Cannot build a dataframe.")
|
|
219
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
220
|
+
for k, v in outputs.items():
|
|
221
|
+
logger.debug(f"Got column {k}, with type [{type(v)}].")
|
|
222
|
+
|
|
223
|
+
length = 0
|
|
224
|
+
index = None
|
|
225
|
+
massaged_outputs = {}
|
|
226
|
+
columns_expected = []
|
|
227
|
+
for k, v in outputs.items():
|
|
228
|
+
if isinstance(v, (dask.dataframe.Series, dask.dataframe.DataFrame)):
|
|
229
|
+
if length == 0:
|
|
230
|
+
length = len(v)
|
|
231
|
+
index = v.index
|
|
232
|
+
massaged_outputs[k] = v
|
|
233
|
+
if isinstance(v, dask.dataframe.Series):
|
|
234
|
+
columns_expected.append(k)
|
|
235
|
+
else:
|
|
236
|
+
columns_expected.extend([get_output_name(k, v_col) for v_col in v.columns])
|
|
237
|
+
elif isinstance(v, (pd.Series, pd.DataFrame)):
|
|
238
|
+
converted = dask.dataframe.from_pandas(v, npartitions=1)
|
|
239
|
+
massaged_outputs[k] = converted
|
|
240
|
+
if length == 0:
|
|
241
|
+
length = len(converted)
|
|
242
|
+
index = converted.index
|
|
243
|
+
if isinstance(v, pd.Series):
|
|
244
|
+
columns_expected.append(k)
|
|
245
|
+
else:
|
|
246
|
+
columns_expected.extend([get_output_name(k, v_col) for v_col in v.columns])
|
|
247
|
+
elif isinstance(v, (np.ndarray, np.generic)):
|
|
248
|
+
massaged_outputs[k] = dask.dataframe.from_array(v)
|
|
249
|
+
columns_expected.append(k)
|
|
250
|
+
elif isinstance(v, (list, tuple)):
|
|
251
|
+
massaged_outputs[k] = dask.dataframe.from_array(dask.array.from_array(v))
|
|
252
|
+
columns_expected.append(k)
|
|
253
|
+
elif isinstance(v, (dask_scalar,)):
|
|
254
|
+
scalar = v.compute()
|
|
255
|
+
if length == 0:
|
|
256
|
+
massaged_outputs[k] = dask.dataframe.from_pandas(
|
|
257
|
+
pd.DataFrame([scalar], index=[0]), npartitions=1
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
massaged_outputs[k] = dask.dataframe.from_pandas(
|
|
261
|
+
pd.DataFrame([scalar] * length, index=index), npartitions=1
|
|
262
|
+
)
|
|
263
|
+
columns_expected.append(k)
|
|
264
|
+
elif isinstance(v, (int, float, str, bool, object)):
|
|
265
|
+
scalar = v
|
|
266
|
+
if length == 0:
|
|
267
|
+
massaged_outputs[k] = dask.dataframe.from_pandas(
|
|
268
|
+
pd.DataFrame([scalar], index=[0]), npartitions=1
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
massaged_outputs[k] = dask.dataframe.from_pandas(
|
|
272
|
+
pd.DataFrame([scalar] * length, index=index), npartitions=1
|
|
273
|
+
)
|
|
274
|
+
columns_expected.append(k)
|
|
275
|
+
else:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Unknown type {type(v)} for output {k}. "
|
|
278
|
+
f"Do not know how to handle making a dataframe from this."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# assumption is that everything here is a dask series or dataframe
|
|
282
|
+
# we assume that we do column concatenation and that it's an outer join (TBD: make this configurable)
|
|
283
|
+
_df = dask.dataframe.concat([o for o in massaged_outputs.values()], axis=1, join="outer")
|
|
284
|
+
_df.columns = columns_expected
|
|
285
|
+
return _df
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# TODO: add ResultMixins for dask types
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class DaskExecutor(executors.TaskExecutor):
|
|
292
|
+
"""A DaskExecutor for task-based execution on dask in the new Hamilton execution API."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, *, client: DaskClient):
|
|
295
|
+
"""Initializes the DaskExecutor. Note this currently takes in the client -- we will likely
|
|
296
|
+
add the ability to make it take in parameters to instantiate/tear down a client on its own.
|
|
297
|
+
This just allows full flexibility for now.
|
|
298
|
+
|
|
299
|
+
"""
|
|
300
|
+
self.client = client
|
|
301
|
+
|
|
302
|
+
def init(self):
|
|
303
|
+
"""No-op -- client already passed in by the user."""
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
def finalize(self):
|
|
307
|
+
"""No-op -- client already passed in by the user, who is responsible for shutting it
|
|
308
|
+
down."""
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def submit_task(self, task: executors.TaskImplementation) -> executors.TaskFuture:
|
|
312
|
+
"""Submits a task using the dask futures API. Note that we are not using dask delayed --
|
|
313
|
+
as the idea is that tasks are potentially dynamic, meaning that we have to resolve some
|
|
314
|
+
before we create others. That makes the delayed API a little messier -- we would have to
|
|
315
|
+
call .compute() at certain steps. We *may* consider doing this, but for now, we are just
|
|
316
|
+
utilizing the futures API, and grouping it into tasks.
|
|
317
|
+
|
|
318
|
+
:param task: Task to execute (contains all arguments necessary)
|
|
319
|
+
:return: The future for the task
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
return executors.TaskFutureWrappingPythonFuture(
|
|
323
|
+
self.client.submit(executors.base_execute_task, task)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def can_submit_task(self) -> bool:
|
|
327
|
+
"""For now we always can -- it will block on the dask side.
|
|
328
|
+
|
|
329
|
+
:return: True
|
|
330
|
+
"""
|
|
331
|
+
return True
|