From 35ea1918ccda036208383bc535da51c830427376 Mon Sep 17 00:00:00 2001 From: zilto Date: Fri, 12 Apr 2024 15:07:43 -0400 Subject: [PATCH 1/7] API design --- hamilton/plugins/dlt_extensions.py | 107 +++++++++++++++++++++++++++++ hamilton/plugins/h_dlt.py | 59 ++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 hamilton/plugins/dlt_extensions.py create mode 100644 hamilton/plugins/h_dlt.py diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py new file mode 100644 index 000000000..eede18445 --- /dev/null +++ b/hamilton/plugins/dlt_extensions.py @@ -0,0 +1,107 @@ +import dataclasses +from typing import Any, Collection, Dict, Iterable, Literal, Optional, Type, Union + +import dlt +import pandas as pd +from dlt import TSecretValue + +# importing TDestinationReferenceArg fails if Destination isn't imported +from dlt.common.destination import Destination, TDestinationReferenceArg # noqa: F401 + +from hamilton import registry +from hamilton.io.data_adapters import DataSaver + +try: + import pyarrow as pa +except ModuleNotFoundError: + SAVER_TYPES = [Iterable, pd.DataFrame] +else: + SAVER_TYPES = [Iterable, pd.DataFrame, pa.Table, pa.RecordBatch] + + +@dataclasses.dataclass +class DltSaver(DataSaver): + """Materialize results using a dlt pipeline with the specified destination. + + In reference to an Extract, Transform, Load (ETL) pipeline, here, the Hamilton + dataflow is responsible for Transform, and `DltDestination` for Load. + """ + + # + table_name: Union[str, dict] + destination: Optional[TDestinationReferenceArg] = None + # kwargs for dlt.pipeline() + pipeline_name: Optional[str] = None + pipelines_dir: Optional[str] = None + pipeline_salt: Optional[TSecretValue] = None + staging: Optional[TDestinationReferenceArg] = None + dataset_name: Optional[str] = None + import_schema_path: Optional[str] = None + export_schema_path: Optional[str] = None + full_refresh: bool = False + credentials: Any = None + # pass a pipeline directly instead of creating it from kwargs + pipeline: Optional[dlt.Pipeline] = None + # kwargs for pipeline.run() + write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None + + @classmethod + def applicable_types(cls) -> Collection[Type]: + return SAVER_TYPES + + def _get_kwargs(self): + """Utility to get kwargs from the class""" + kwargs = {} + for field in dataclasses.fields(self): + if field.name in ["write_disposition", "pipeline", "table_name"]: + continue + + field_value = getattr(self, field.name) + if field_value != field.default: + kwargs[field.name] = field_value + + return kwargs + + def save_data(self, data) -> Dict[str, Any]: + """ + ref: + tables: https://dlthub.com/docs/dlt-ecosystem/verified-sources/arrow-pandas + standalone resources: https://dlthub.com/docs/general-usage/resource#load-resources + dynamic resources: https://dlthub.com/docs/general-usage/source#create-resources-dynamically + """ + + # check if a pipeline was passed directly instead of kwargs + if self.pipeline: + if isinstance(self.pipeline, dlt.Pipeline): + pipeline = self.pipeline + else: + raise TypeError( + f"DltDataser: `pipeline` argument should be of type `dlt.Pipeline` received {type(self.pipeline)} instead" + ) + else: + pipeline = dlt.pipeline(**self._get_kwargs()) + + # if `combine` was used in `to.dlt(..., combine=base.DictResult())`, we save multiple + # tables via the same pipeline + if isinstance(data, dict): + resources = [dlt.resource(value, name=name) for name, value in data.items()] + else: + resources = [dlt.resource([data], name=__name__)] + + load_info = pipeline.run(resources) + return load_info.asdict() + + @classmethod + def name(cls) -> str: + return "dlt" + + +def register_data_loaders(): + """Function to register the data loaders for this extension.""" + for loader in [ + DltSaver, + ]: + registry.register_adapter(loader) + + +register_data_loaders() diff --git a/hamilton/plugins/h_dlt.py b/hamilton/plugins/h_dlt.py new file mode 100644 index 000000000..c62274e91 --- /dev/null +++ b/hamilton/plugins/h_dlt.py @@ -0,0 +1,59 @@ +import logging +from typing import Any, Dict, List, Literal, Optional, Union + +import dlt + +from hamilton import htypes +from hamilton.graph_types import HamiltonGraph +from hamilton.lifecycle import GraphExecutionHook +from hamilton.plugins.dlt_extensions import DltSaver + +logger = logging.getLogger(__name__) + + +class DltAdapter(GraphExecutionHook): + def __init__( + self, + pipeline: dlt.Pipeline, + write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None, + show_load_info: bool = False, + ): + """ + + This is the preferred way to interface between dlt and Hamilton. All nodes requested for execution + via `final_vars` will be materialized via a single pipeline. + + If you need more control, you can use the `DltSaver` from `hamilton.plugins.dlt_extensions` directly + to dispatch materialize single or group of nodes to specific pipelines. + """ + self.pipeline = pipeline + self.write_disposition = write_disposition + self.show_load_info = show_load_info + + def run_before_graph_execution(self, *, graph: HamiltonGraph, final_vars: List[str], **kwargs): + self.final_vars = final_vars + + valid_type_union = Union[tuple(DltSaver.applicable_types())] + for node in graph.nodes: + if node.name not in final_vars: + continue + + # TODO this check by the lifecycle hook can't raise an error to prevent execution + if htypes.custom_subclass_check(node.type, valid_type_union) is False: + logger.warn( + f"Requested node `{node.name}` isn't a supported type by `DltAdapter`: {valid_type_union}" + ) + + # TODO add logic to handle sources + + # TODO why is `results` typed optional by default? + def run_after_graph_execution(self, *, results: Dict[str, Any], **kwargs): + data_saver = DltSaver( + table_name={node_name: node_name for node_name in self.final_vars}, + pipeline=self.pipeline, + write_disposition=self.write_disposition, + ) + load_info: dict = data_saver.save_data(results) + + if self.show_load_info: + print(load_info) From b4f4c29b87626d5c687028e09190012fe133c642 Mon Sep 17 00:00:00 2001 From: zilto Date: Fri, 12 Apr 2024 15:50:27 -0400 Subject: [PATCH 2/7] streamline DltSaver API --- hamilton/plugins/dlt_extensions.py | 66 ++++++++---------------------- 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py index eede18445..bf8b4acdb 100644 --- a/hamilton/plugins/dlt_extensions.py +++ b/hamilton/plugins/dlt_extensions.py @@ -1,9 +1,8 @@ import dataclasses -from typing import Any, Collection, Dict, Iterable, Literal, Optional, Type, Union +from typing import Any, Collection, Dict, Iterable, Literal, Optional, Type import dlt import pandas as pd -from dlt import TSecretValue # importing TDestinationReferenceArg fails if Destination isn't imported from dlt.common.destination import Destination, TDestinationReferenceArg # noqa: F401 @@ -27,21 +26,8 @@ class DltSaver(DataSaver): dataflow is responsible for Transform, and `DltDestination` for Load. """ - # - table_name: Union[str, dict] - destination: Optional[TDestinationReferenceArg] = None - # kwargs for dlt.pipeline() - pipeline_name: Optional[str] = None - pipelines_dir: Optional[str] = None - pipeline_salt: Optional[TSecretValue] = None - staging: Optional[TDestinationReferenceArg] = None - dataset_name: Optional[str] = None - import_schema_path: Optional[str] = None - export_schema_path: Optional[str] = None - full_refresh: bool = False - credentials: Any = None - # pass a pipeline directly instead of creating it from kwargs - pipeline: Optional[dlt.Pipeline] = None + pipeline: dlt.Pipeline + table_name: Optional[dict] = None # kwargs for pipeline.run() write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None @@ -49,19 +35,6 @@ class DltSaver(DataSaver): def applicable_types(cls) -> Collection[Type]: return SAVER_TYPES - def _get_kwargs(self): - """Utility to get kwargs from the class""" - kwargs = {} - for field in dataclasses.fields(self): - if field.name in ["write_disposition", "pipeline", "table_name"]: - continue - - field_value = getattr(self, field.name) - if field_value != field.default: - kwargs[field.name] = field_value - - return kwargs - def save_data(self, data) -> Dict[str, Any]: """ ref: @@ -69,26 +42,21 @@ def save_data(self, data) -> Dict[str, Any]: standalone resources: https://dlthub.com/docs/general-usage/resource#load-resources dynamic resources: https://dlthub.com/docs/general-usage/source#create-resources-dynamically """ + if isinstance(data, dict) is False: # only 1 node, no `combine=base.DictResult()` + raise NotImplementedError( + f"DltSaver needs to a receive a `Dict[str, {SAVER_TYPES}]`" + f"When using `to.dlt()`, make sure to specify `combine=base.DictResult()`" + ) + + if self.table_name is None: + self.table_name = dict() + + resources = [] + for node_name, result in data.items(): + name = self.table_name.get(node_name, node_name) + resources.append(dlt.resource(result, name=name)) - # check if a pipeline was passed directly instead of kwargs - if self.pipeline: - if isinstance(self.pipeline, dlt.Pipeline): - pipeline = self.pipeline - else: - raise TypeError( - f"DltDataser: `pipeline` argument should be of type `dlt.Pipeline` received {type(self.pipeline)} instead" - ) - else: - pipeline = dlt.pipeline(**self._get_kwargs()) - - # if `combine` was used in `to.dlt(..., combine=base.DictResult())`, we save multiple - # tables via the same pipeline - if isinstance(data, dict): - resources = [dlt.resource(value, name=name) for name, value in data.items()] - else: - resources = [dlt.resource([data], name=__name__)] - - load_info = pipeline.run(resources) + load_info = self.pipeline.run(resources, write_disposition=self.write_disposition) return load_info.asdict() @classmethod From b94b6c8a42f6610d594dc6b4afbc5f274f275b92 Mon Sep 17 00:00:00 2001 From: zilto Date: Fri, 12 Apr 2024 15:55:33 -0400 Subject: [PATCH 3/7] changed to --- hamilton/plugins/dlt_extensions.py | 7 +++---- hamilton/plugins/h_dlt.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py index bf8b4acdb..e2a3ac6ec 100644 --- a/hamilton/plugins/dlt_extensions.py +++ b/hamilton/plugins/dlt_extensions.py @@ -27,7 +27,7 @@ class DltSaver(DataSaver): """ pipeline: dlt.Pipeline - table_name: Optional[dict] = None + rename: Optional[dict] = None # kwargs for pipeline.run() write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None @@ -48,12 +48,11 @@ def save_data(self, data) -> Dict[str, Any]: f"When using `to.dlt()`, make sure to specify `combine=base.DictResult()`" ) - if self.table_name is None: - self.table_name = dict() + rename = {} if self.rename is None else self.rename resources = [] for node_name, result in data.items(): - name = self.table_name.get(node_name, node_name) + name = rename.get(node_name, node_name) resources.append(dlt.resource(result, name=name)) load_info = self.pipeline.run(resources, write_disposition=self.write_disposition) diff --git a/hamilton/plugins/h_dlt.py b/hamilton/plugins/h_dlt.py index c62274e91..a1f845401 100644 --- a/hamilton/plugins/h_dlt.py +++ b/hamilton/plugins/h_dlt.py @@ -15,6 +15,7 @@ class DltAdapter(GraphExecutionHook): def __init__( self, pipeline: dlt.Pipeline, + rename: Optional[dict] = None, write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None, show_load_info: bool = False, ): @@ -27,6 +28,7 @@ def __init__( to dispatch materialize single or group of nodes to specific pipelines. """ self.pipeline = pipeline + self.rename = rename self.write_disposition = write_disposition self.show_load_info = show_load_info @@ -49,8 +51,8 @@ def run_before_graph_execution(self, *, graph: HamiltonGraph, final_vars: List[s # TODO why is `results` typed optional by default? def run_after_graph_execution(self, *, results: Dict[str, Any], **kwargs): data_saver = DltSaver( - table_name={node_name: node_name for node_name in self.final_vars}, pipeline=self.pipeline, + rename=self.rename, write_disposition=self.write_disposition, ) load_info: dict = data_saver.save_data(results) From 34bdb0dc1014ecbe811c486c6db49e7153416e22 Mon Sep 17 00:00:00 2001 From: zilto Date: Sun, 14 Apr 2024 18:02:51 -0400 Subject: [PATCH 4/7] added DataLoader --- examples/dlt/dlt_plugin.ipynb | 351 +++++++++++++++++++++++++++++ hamilton/plugins/dlt_extensions.py | 114 +++++++--- hamilton/plugins/h_dlt.py | 61 ----- 3 files changed, 435 insertions(+), 91 deletions(-) create mode 100644 examples/dlt/dlt_plugin.ipynb delete mode 100644 hamilton/plugins/h_dlt.py diff --git a/examples/dlt/dlt_plugin.ipynb b/examples/dlt/dlt_plugin.ipynb new file mode 100644 index 000000000..12e1b5c4e --- /dev/null +++ b/examples/dlt/dlt_plugin.ipynb @@ -0,0 +1,351 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# dlt plugin for Hamilton\n", + "This notebook shows how to use Hamilton [materializers](https://hamilton.dagworks.io/en/latest/concepts/materialization/) to move data between Hamilton and dlt.\n", + "\n", + "Content:\n", + "1. Defining an illustrative Hamilton dataflow\n", + "2. `DataSaver`: save Hamilton results to a [dlt Destination](https://dlthub.com/docs/dlt-ecosystem/destinations/)\n", + "3. `DataLoader`: load data from a [dlt Resource](https://dlthub.com/docs/dlt-ecosystem/verified-sources/) (a single table from a Source) into a Hamilton node" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext hamilton.plugins.jupyter_magic\n", + "\n", + "import dlt\n", + "from hamilton import driver\n", + "from hamilton.io.materialization import to, from_\n", + "from hamilton.plugins import dlt_extensions" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "%3\n", + "\n", + "\n", + "cluster__legend\n", + "\n", + "Legend\n", + "\n", + "\n", + "\n", + "print_df_head\n", + "\n", + "print_df_head\n", + "DataFrame\n", + "\n", + "\n", + "\n", + "table\n", + "\n", + "table\n", + "DataFrame\n", + "\n", + "\n", + "\n", + "_print_df_head_inputs\n", + "\n", + "external\n", + "DataFrame\n", + "\n", + "\n", + "\n", + "_print_df_head_inputs->print_df_head\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "input\n", + "\n", + "input\n", + "\n", + "\n", + "\n", + "function\n", + "\n", + "function\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%cell_to_module -m my_module -d\n", + "import pandas as pd\n", + "\n", + "def table() -> pd.DataFrame:\n", + " return pd.DataFrame([{\"C\": 1}, {\"C\": 2}])\n", + "\n", + "def print_df_head(external: pd.DataFrame) -> pd.DataFrame:\n", + " print(\"from print_df_head:\\n\", external.head())" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "dr = driver.Builder().with_modules(my_module).build()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## DataSaver\n", + "With \"Extract, Transform, Load\" (ETL) as frame of reference, here, the Hamilton dataflow is responsible for Transform, and `DltDestination` for Load.\n", + "\n", + "\n", + "Start by defining a dlt `Pipeline` that uses your chosen dlt Destination. This is regular dlt code that you will pass to Hamilton." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "saver_pipeline = dlt.pipeline(pipeline_name=\"saver_pipe\", destination=\"duckdb\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Single dependency\n", + "Define the materializer with `to.dlt()` the example below shows required arguments. You specify an `id` for the materializer and `dependencies` includes the name of a single Hamilton node. Then, specify a `table_name` for the destination and pass the `pipeline`. \n", + "\n", + "The [other keyword arguments](https://dlthub.com/docs/api_reference/pipeline/__init__#run) for `dlt.pipeline.run()` are accepted and allow specifying incremental loading, table schema annotation, and more." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'pipeline': {'pipeline_name': 'saver_pipe'}, 'metrics': [{'started_at': DateTime(2024, 4, 14, 21, 56, 41, 147097, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 14, 21, 56, 41, 320724, tzinfo=Timezone('UTC')), 'load_id': '1713131800.765432'}], 'destination_type': 'dlt.destinations.duckdb', 'destination_displayable_credentials': 'duckdb:////home/tjean/projects/dagworks/hamilton/examples/dlt/saver_pipe.duckdb', 'destination_name': 'duckdb', 'environment': None, 'staging_type': None, 'staging_name': None, 'staging_displayable_credentials': None, 'destination_fingerprint': '', 'dataset_name': 'saver_pipe_dataset', 'loads_ids': ['1713131800.765432'], 'load_packages': [{'load_id': '1713131800.765432', 'package_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713131800.765432', 'state': 'loaded', 'completed_at': DateTime(2024, 4, 14, 21, 56, 41, 306600, tzinfo=Timezone('UTC')), 'jobs': [{'state': 'completed_jobs', 'file_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713131800.765432/completed_jobs/my_table.a17fa20182.0.parquet', 'file_size': 574, 'created_at': DateTime(2024, 4, 14, 21, 56, 40, 776600, tzinfo=Timezone('UTC')), 'elapsed': 0.5299997329711914, 'failed_message': None, 'table_name': 'my_table', 'file_id': 'a17fa20182', 'retry_count': 0, 'file_format': 'parquet'}], 'schema_hash': 'UE8l1iVz3xnHM+zYpjm8Bqd+3m6rDG++zNubWIUyecg=', 'schema_name': 'saver_pipe', 'tables': []}], 'first_run': False, 'started_at': DateTime(2024, 4, 14, 21, 56, 41, 147097, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 14, 21, 56, 41, 320724, tzinfo=Timezone('UTC'))}\n" + ] + }, + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "%3\n", + "\n", + "\n", + "cluster__legend\n", + "\n", + "Legend\n", + "\n", + "\n", + "\n", + "saver_node\n", + "\n", + "\n", + "saver_node\n", + "DltDestinationSaver\n", + "\n", + "\n", + "\n", + "table\n", + "\n", + "table\n", + "DataFrame\n", + "\n", + "\n", + "\n", + "table->saver_node\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "function\n", + "\n", + "function\n", + "\n", + "\n", + "\n", + "output\n", + "\n", + "output\n", + "\n", + "\n", + "\n", + "materializer\n", + "\n", + "\n", + "materializer\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "materializers = [\n", + " to.dlt(\n", + " id=\"saver_node\",\n", + " dependencies=[\"table\"],\n", + " table_name=\"my_table\",\n", + " pipeline=saver_pipeline,\n", + " )\n", + "]\n", + "metadata, _ = dr.materialize(*materializers)\n", + "print(metadata[\"saver_node\"])\n", + "dr.visualize_materialization(*materializers)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## DataLoader\n", + "With ETL as a frame of reference, the `DataLoader` uses dlt to run the \"Extract\" step for the passed dlt `Resource`. \n", + "\n", + "Internally, it creates a temporary dlt Pipeline to run the extract and normalize steps then reads the files in-memory. The dlt Pipeline is then deleted. " + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# this is a mock dlt Source for demo purposes\n", + "\n", + "from typing import Iterable\n", + "from dlt.extract import DltResource\n", + "from dlt.common.typing import TDataItem\n", + "\n", + "@dlt.source\n", + "def mock_source() -> Iterable[DltResource]:\n", + " iterable_data = [{\"col\": 1}, {\"col\": 2}, {\"col\": 3}] * 100\n", + " \n", + " @dlt.resource\n", + " def mock_resource() -> Iterable[TDataItem]:\n", + " yield from iterable_data\n", + " \n", + " yield mock_resource\n", + " \n", + " \n", + "my_mock_source = mock_source()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Single resource\n", + "To define the materializer, give it a `target` Hamilton node and pass a dlt Resource to `resource`. When working with a dlt Source, you can access individual resources via the dictionary `Source.resource[RESOURCE_NAME]`" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "from print_df_head:\n", + " col _dlt_load_id _dlt_id\n", + "0 1 1713131801.3954566 pIOVDF0PSQez4g\n", + "1 2 1713131801.3954566 V39tnCZ5OHJS8A\n", + "2 3 1713131801.3954566 Neg2YxZXqbtDdg\n", + "3 1 1713131801.3954566 18GzdWmzRuFGFQ\n", + "4 2 1713131801.3954566 fRs/oHZpBQbEIg\n" + ] + } + ], + "source": [ + "materializers = [\n", + " from_.dlt(\n", + " target=\"external\",\n", + " resource=my_mock_source.resources[\"mock_resource\"],\n", + " ),\n", + "]\n", + "\n", + "metadata, _ = dr.materialize(\n", + " *materializers,\n", + " additional_vars=[\"print_df_head\"]\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py index e2a3ac6ec..f36ecda29 100644 --- a/hamilton/plugins/dlt_extensions.py +++ b/hamilton/plugins/dlt_extensions.py @@ -1,25 +1,72 @@ import dataclasses -from typing import Any, Collection, Dict, Iterable, Literal, Optional, Type +from typing import Any, Collection, Dict, Iterable, Literal, Optional, Sequence, Tuple, Type import dlt import pandas as pd +from dlt.common.destination import Destination, TDestinationReferenceArg # noqa: F401 +from dlt.common.schema import Schema, TColumnSchema # importing TDestinationReferenceArg fails if Destination isn't imported -from dlt.common.destination import Destination, TDestinationReferenceArg # noqa: F401 +from dlt.extract.resource import DltResource from hamilton import registry -from hamilton.io.data_adapters import DataSaver +from hamilton.io import utils +from hamilton.io.data_adapters import DataLoader, DataSaver + +DATAFRAME_TYPES = [Iterable, pd.DataFrame] +# TODO add types for other Dataframe libraries try: import pyarrow as pa + + DATAFRAME_TYPES.extend([pa.Table, pa.RecordBatch]) except ModuleNotFoundError: - SAVER_TYPES = [Iterable, pd.DataFrame] -else: - SAVER_TYPES = [Iterable, pd.DataFrame, pa.Table, pa.RecordBatch] + pass + +# convert to tuple to dynamically define type `Union[DATAFRAME_TYPES]` +DATAFRAME_TYPES = tuple(DATAFRAME_TYPES) + + +# TODO use `driver.validate_materialization` +@dataclasses.dataclass +class DltResourceLoader(DataLoader): + resource: DltResource + + @classmethod + def name(cls) -> str: + return "dlt" + + @classmethod + def applicable_types(cls) -> Collection[Type]: + return DATAFRAME_TYPES + + def load_data(self, type_: Type) -> Tuple[pd.DataFrame, Dict[str, Any]]: + """Creates a pipeline and conduct `extract` and `normalize` steps. + Then, "load packages" are read with pandas + """ + pipeline = dlt.pipeline(pipeline_name="Hamilton-DltResourceLoader") + pipeline.extract(self.resource) + normalize_info = pipeline.normalize(loader_file_format="parquet") + partition_file_paths = [] + for package in normalize_info.load_packages: + load_info = package.jobs["new_jobs"][0] + partition_file_paths.append(load_info.file_path) + # TODO use pyarrow directly to support different dataframe libraries + # ref: https://arrow.apache.org/docs/python/generated/pyarrow.parquet.ParquetDataset.html#pyarrow.parquet.ParquetDataset + df = pd.concat([pd.read_parquet(f) for f in partition_file_paths], ignore_index=True) + + # delete the pipeline + pipeline.drop() + + metadata = utils.get_dataframe_metadata(df) + return df, metadata + + +# TODO handle behavior with `combine=`, currently only supports materializing a single node @dataclasses.dataclass -class DltSaver(DataSaver): +class DltDestinationSaver(DataSaver): """Materialize results using a dlt pipeline with the specified destination. In reference to an Extract, Transform, Load (ETL) pipeline, here, the Hamilton @@ -27,46 +74,53 @@ class DltSaver(DataSaver): """ pipeline: dlt.Pipeline - rename: Optional[dict] = None - # kwargs for pipeline.run() + table_name: str + primary_key: Optional[str] = None write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None + columns: Optional[Sequence[TColumnSchema]] = None + schema: Optional[Schema] = None + + @classmethod + def name(cls) -> str: + return "dlt" @classmethod def applicable_types(cls) -> Collection[Type]: - return SAVER_TYPES + return DATAFRAME_TYPES + + def _get_kwargs(self) -> dict: + kwargs = {} + fields_to_skip = ["pipeline"] + for field in dataclasses.fields(self): + field_value = getattr(self, field.name) + if field.name in fields_to_skip: + continue + if field_value != field.default: + kwargs[field.name] = field_value + + return kwargs + + # TODO get pyarrow table from polars, dask, etc. def save_data(self, data) -> Dict[str, Any]: """ - ref: - tables: https://dlthub.com/docs/dlt-ecosystem/verified-sources/arrow-pandas - standalone resources: https://dlthub.com/docs/general-usage/resource#load-resources - dynamic resources: https://dlthub.com/docs/general-usage/source#create-resources-dynamically + ref: https://dlthub.com/docs/dlt-ecosystem/verified-sources/arrow-pandas """ - if isinstance(data, dict) is False: # only 1 node, no `combine=base.DictResult()` + if isinstance(data, dict): raise NotImplementedError( - f"DltSaver needs to a receive a `Dict[str, {SAVER_TYPES}]`" - f"When using `to.dlt()`, make sure to specify `combine=base.DictResult()`" + "DltDestinationSaver received data of type `dict`." + "Currently, it doesn't support specifying `combine=base.DictResult()`" ) - rename = {} if self.rename is None else self.rename - - resources = [] - for node_name, result in data.items(): - name = rename.get(node_name, node_name) - resources.append(dlt.resource(result, name=name)) - - load_info = self.pipeline.run(resources, write_disposition=self.write_disposition) + load_info = self.pipeline.run(data, **self._get_kwargs()) return load_info.asdict() - @classmethod - def name(cls) -> str: - return "dlt" - def register_data_loaders(): """Function to register the data loaders for this extension.""" for loader in [ - DltSaver, + DltDestinationSaver, + DltResourceLoader, ]: registry.register_adapter(loader) diff --git a/hamilton/plugins/h_dlt.py b/hamilton/plugins/h_dlt.py deleted file mode 100644 index a1f845401..000000000 --- a/hamilton/plugins/h_dlt.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -from typing import Any, Dict, List, Literal, Optional, Union - -import dlt - -from hamilton import htypes -from hamilton.graph_types import HamiltonGraph -from hamilton.lifecycle import GraphExecutionHook -from hamilton.plugins.dlt_extensions import DltSaver - -logger = logging.getLogger(__name__) - - -class DltAdapter(GraphExecutionHook): - def __init__( - self, - pipeline: dlt.Pipeline, - rename: Optional[dict] = None, - write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None, - show_load_info: bool = False, - ): - """ - - This is the preferred way to interface between dlt and Hamilton. All nodes requested for execution - via `final_vars` will be materialized via a single pipeline. - - If you need more control, you can use the `DltSaver` from `hamilton.plugins.dlt_extensions` directly - to dispatch materialize single or group of nodes to specific pipelines. - """ - self.pipeline = pipeline - self.rename = rename - self.write_disposition = write_disposition - self.show_load_info = show_load_info - - def run_before_graph_execution(self, *, graph: HamiltonGraph, final_vars: List[str], **kwargs): - self.final_vars = final_vars - - valid_type_union = Union[tuple(DltSaver.applicable_types())] - for node in graph.nodes: - if node.name not in final_vars: - continue - - # TODO this check by the lifecycle hook can't raise an error to prevent execution - if htypes.custom_subclass_check(node.type, valid_type_union) is False: - logger.warn( - f"Requested node `{node.name}` isn't a supported type by `DltAdapter`: {valid_type_union}" - ) - - # TODO add logic to handle sources - - # TODO why is `results` typed optional by default? - def run_after_graph_execution(self, *, results: Dict[str, Any], **kwargs): - data_saver = DltSaver( - pipeline=self.pipeline, - rename=self.rename, - write_disposition=self.write_disposition, - ) - load_info: dict = data_saver.save_data(results) - - if self.show_load_info: - print(load_info) From 1e930705b4b8c0277dea16b2bdd528e906aa1546 Mon Sep 17 00:00:00 2001 From: zilto Date: Mon, 15 Apr 2024 16:20:12 -0400 Subject: [PATCH 5/7] added tests, fixed bug --- examples/dlt/dlt_plugin.ipynb | 98 +++++++++++++--------------- hamilton/plugins/dlt_extensions.py | 21 +++--- requirements-docs.txt | 3 +- requirements-test.txt | 1 + tests/plugins/test_dlt_extensions.py | 45 +++++++++++++ 5 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 tests/plugins/test_dlt_extensions.py diff --git a/examples/dlt/dlt_plugin.ipynb b/examples/dlt/dlt_plugin.ipynb index 12e1b5c4e..032bff17c 100644 --- a/examples/dlt/dlt_plugin.ipynb +++ b/examples/dlt/dlt_plugin.ipynb @@ -41,60 +41,60 @@ "\n", "\n", - "\n", - "\n", + "\n", + "\n", "%3\n", - "\n", + "\n", "\n", "cluster__legend\n", - "\n", - "Legend\n", + "\n", + "Legend\n", "\n", - "\n", + "\n", "\n", - "print_df_head\n", - "\n", - "print_df_head\n", - "DataFrame\n", + "table\n", + "\n", + "table\n", + "DataFrame\n", "\n", - "\n", + "\n", "\n", - "table\n", - "\n", - "table\n", - "DataFrame\n", + "print_df_head\n", + "\n", + "print_df_head\n", + "DataFrame\n", "\n", "\n", "\n", "_print_df_head_inputs\n", - "\n", - "external\n", - "DataFrame\n", + "\n", + "external\n", + "DataFrame\n", "\n", "\n", "\n", "_print_df_head_inputs->print_df_head\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "input\n", - "\n", - "input\n", + "\n", + "input\n", "\n", "\n", "\n", "function\n", - "\n", - "function\n", + "\n", + "function\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -161,7 +161,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'pipeline': {'pipeline_name': 'saver_pipe'}, 'metrics': [{'started_at': DateTime(2024, 4, 14, 21, 56, 41, 147097, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 14, 21, 56, 41, 320724, tzinfo=Timezone('UTC')), 'load_id': '1713131800.765432'}], 'destination_type': 'dlt.destinations.duckdb', 'destination_displayable_credentials': 'duckdb:////home/tjean/projects/dagworks/hamilton/examples/dlt/saver_pipe.duckdb', 'destination_name': 'duckdb', 'environment': None, 'staging_type': None, 'staging_name': None, 'staging_displayable_credentials': None, 'destination_fingerprint': '', 'dataset_name': 'saver_pipe_dataset', 'loads_ids': ['1713131800.765432'], 'load_packages': [{'load_id': '1713131800.765432', 'package_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713131800.765432', 'state': 'loaded', 'completed_at': DateTime(2024, 4, 14, 21, 56, 41, 306600, tzinfo=Timezone('UTC')), 'jobs': [{'state': 'completed_jobs', 'file_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713131800.765432/completed_jobs/my_table.a17fa20182.0.parquet', 'file_size': 574, 'created_at': DateTime(2024, 4, 14, 21, 56, 40, 776600, tzinfo=Timezone('UTC')), 'elapsed': 0.5299997329711914, 'failed_message': None, 'table_name': 'my_table', 'file_id': 'a17fa20182', 'retry_count': 0, 'file_format': 'parquet'}], 'schema_hash': 'UE8l1iVz3xnHM+zYpjm8Bqd+3m6rDG++zNubWIUyecg=', 'schema_name': 'saver_pipe', 'tables': []}], 'first_run': False, 'started_at': DateTime(2024, 4, 14, 21, 56, 41, 147097, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 14, 21, 56, 41, 320724, tzinfo=Timezone('UTC'))}\n" + "{'dlt_metadata': {'pipeline': {'pipeline_name': 'saver_pipe'}, 'metrics': [{'started_at': DateTime(2024, 4, 15, 20, 18, 40, 423767, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 15, 20, 18, 40, 587988, tzinfo=Timezone('UTC')), 'load_id': '1713212320.0496533'}], 'destination_type': 'dlt.destinations.duckdb', 'destination_displayable_credentials': 'duckdb:////home/tjean/projects/dagworks/hamilton/examples/dlt/saver_pipe.duckdb', 'destination_name': 'duckdb', 'environment': None, 'staging_type': None, 'staging_name': None, 'staging_displayable_credentials': None, 'destination_fingerprint': '', 'dataset_name': 'saver_pipe_dataset', 'loads_ids': ['1713212320.0496533'], 'load_packages': [{'load_id': '1713212320.0496533', 'package_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713212320.0496533', 'state': 'loaded', 'completed_at': DateTime(2024, 4, 15, 20, 18, 40, 570607, tzinfo=Timezone('UTC')), 'jobs': [{'state': 'completed_jobs', 'file_path': '/home/tjean/.dlt/pipelines/saver_pipe/load/loaded/1713212320.0496533/completed_jobs/my_table.7352fcd48a.0.parquet', 'file_size': 574, 'created_at': DateTime(2024, 4, 15, 20, 18, 40, 60607, tzinfo=Timezone('UTC')), 'elapsed': 0.5100002288818359, 'failed_message': None, 'table_name': 'my_table', 'file_id': '7352fcd48a', 'retry_count': 0, 'file_format': 'parquet'}], 'schema_hash': 'UE8l1iVz3xnHM+zYpjm8Bqd+3m6rDG++zNubWIUyecg=', 'schema_name': 'saver_pipe', 'tables': []}], 'first_run': False, 'started_at': DateTime(2024, 4, 15, 20, 18, 40, 423767, tzinfo=Timezone('UTC')), 'finished_at': DateTime(2024, 4, 15, 20, 18, 40, 587988, tzinfo=Timezone('UTC'))}}\n" ] }, { @@ -183,21 +183,21 @@ "\n", "Legend\n", "\n", - "\n", + "\n", "\n", + "table\n", + "\n", + "table\n", + "DataFrame\n", + "\n", + "\n", + "\n", "saver_node\n", "\n", "\n", "saver_node\n", "DltDestinationSaver\n", "\n", - "\n", - "\n", - "table\n", - "\n", - "table\n", - "DataFrame\n", - "\n", "\n", "\n", "table->saver_node\n", @@ -227,7 +227,7 @@ "\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -244,8 +244,8 @@ " pipeline=saver_pipeline,\n", " )\n", "]\n", - "metadata, _ = dr.materialize(*materializers)\n", - "print(metadata[\"saver_node\"])\n", + "results, _ = dr.materialize(*materializers)\n", + "print(results[\"saver_node\"])\n", "dr.visualize_materialization(*materializers)" ] }, @@ -266,22 +266,16 @@ "outputs": [], "source": [ "# this is a mock dlt Source for demo purposes\n", - "\n", - "from typing import Iterable\n", - "from dlt.extract import DltResource\n", - "from dlt.common.typing import TDataItem\n", - "\n", "@dlt.source\n", - "def mock_source() -> Iterable[DltResource]:\n", + "def mock_source():\n", " iterable_data = [{\"col\": 1}, {\"col\": 2}, {\"col\": 3}] * 100\n", " \n", " @dlt.resource\n", - " def mock_resource() -> Iterable[TDataItem]:\n", + " def mock_resource():\n", " yield from iterable_data\n", " \n", " yield mock_resource\n", " \n", - " \n", "my_mock_source = mock_source()" ] }, @@ -303,12 +297,12 @@ "output_type": "stream", "text": [ "from print_df_head:\n", - " col _dlt_load_id _dlt_id\n", - "0 1 1713131801.3954566 pIOVDF0PSQez4g\n", - "1 2 1713131801.3954566 V39tnCZ5OHJS8A\n", - "2 3 1713131801.3954566 Neg2YxZXqbtDdg\n", - "3 1 1713131801.3954566 18GzdWmzRuFGFQ\n", - "4 2 1713131801.3954566 fRs/oHZpBQbEIg\n" + " col _dlt_load_id _dlt_id\n", + "0 1 1713212320.641402 vG/cmM5Ty/F/WQ\n", + "1 2 1713212320.641402 xZ/tUsoBiWTneQ\n", + "2 3 1713212320.641402 d+8Ah9hx2214Vw\n", + "3 1 1713212320.641402 XxYKANM6PlMl2A\n", + "4 2 1713212320.641402 jnnYhXp5KA2NsQ\n" ] } ], diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py index f36ecda29..6ceb8854a 100644 --- a/hamilton/plugins/dlt_extensions.py +++ b/hamilton/plugins/dlt_extensions.py @@ -3,7 +3,7 @@ import dlt import pandas as pd -from dlt.common.destination import Destination, TDestinationReferenceArg # noqa: F401 +from dlt.common.destination.capabilities import TLoaderFileFormat from dlt.common.schema import Schema, TColumnSchema # importing TDestinationReferenceArg fails if Destination isn't imported @@ -27,7 +27,6 @@ DATAFRAME_TYPES = tuple(DATAFRAME_TYPES) -# TODO use `driver.validate_materialization` @dataclasses.dataclass class DltResourceLoader(DataLoader): resource: DltResource @@ -38,22 +37,26 @@ def name(cls) -> str: @classmethod def applicable_types(cls) -> Collection[Type]: - return DATAFRAME_TYPES + return [pd.DataFrame] def load_data(self, type_: Type) -> Tuple[pd.DataFrame, Dict[str, Any]]: """Creates a pipeline and conduct `extract` and `normalize` steps. Then, "load packages" are read with pandas """ - pipeline = dlt.pipeline(pipeline_name="Hamilton-DltResourceLoader") + pipeline = dlt.pipeline( + pipeline_name="Hamilton-DltResourceLoader", destination="filesystem" + ) pipeline.extract(self.resource) normalize_info = pipeline.normalize(loader_file_format="parquet") partition_file_paths = [] - for package in normalize_info.load_packages: - load_info = package.jobs["new_jobs"][0] - partition_file_paths.append(load_info.file_path) + package = normalize_info.load_packages[0] + for job in package.jobs["new_jobs"]: + if job.job_file_info.table_name == self.resource.name: + partition_file_paths.append(job.file_path) # TODO use pyarrow directly to support different dataframe libraries + # ref: https://github.com/dlt-hub/verified-sources/blob/master/sources/filesystem/readers.py # ref: https://arrow.apache.org/docs/python/generated/pyarrow.parquet.ParquetDataset.html#pyarrow.parquet.ParquetDataset df = pd.concat([pd.read_parquet(f) for f in partition_file_paths], ignore_index=True) @@ -79,6 +82,7 @@ class DltDestinationSaver(DataSaver): write_disposition: Optional[Literal["skip", "append", "replace", "merge"]] = None columns: Optional[Sequence[TColumnSchema]] = None schema: Optional[Schema] = None + loader_file_format: Optional[TLoaderFileFormat] = None @classmethod def name(cls) -> str: @@ -113,7 +117,8 @@ def save_data(self, data) -> Dict[str, Any]: ) load_info = self.pipeline.run(data, **self._get_kwargs()) - return load_info.asdict() + # follows the pattern of metadata output found in hamilton.io.utils + return {"dlt_metadata": load_info.asdict()} def register_data_loaders(): diff --git a/requirements-docs.txt b/requirements-docs.txt index ffbc66d31..1cc78c496 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -5,6 +5,8 @@ dask-expr dask[distributed] ddtrace diskcache +# required for all the plugins +dlt # furo -- install from main for now until the next release is out: git+https://github.com/pradyunsg/furo@main gitpython # Required for parsing git info for generation of data-adapter docs @@ -16,7 +18,6 @@ mock==1.0.1 # read the docs pins myst-parser==2.0.0 # latest version of myst at this time pandera pillow -# required for all the plugins polars pyarrow >= 1.0.0 pyspark diff --git a/requirements-test.txt b/requirements-test.txt index 881815c32..da714f6bf 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,6 +2,7 @@ connectorx dask dask-expr; python_version >= '3.9' diskcache +dlt fsspec graphviz kaleido diff --git a/tests/plugins/test_dlt_extensions.py b/tests/plugins/test_dlt_extensions.py new file mode 100644 index 000000000..cc320b584 --- /dev/null +++ b/tests/plugins/test_dlt_extensions.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import dlt +import pandas as pd +import pyarrow as pa +import pytest +from dlt.destinations import filesystem + +from hamilton.plugins.dlt_extensions import DltDestinationSaver, DltResourceLoader + + +def pandas_df(): + return pd.DataFrame({"a": [1, 2], "b": [1, 2]}) + + +def iterable(): + return [{"a": 1, "b": 3}, {"a": 2, "b": 4}] + + +def pyarrow_table(): + col_a = pa.array([1, 2]) + col_b = pa.array([3, 4]) + return pa.Table.from_arrays([col_a, col_b], names=["a", "b"]) + + +@pytest.mark.parametrize("data", [iterable(), pandas_df(), pyarrow_table()]) +def test_dlt_destination_saver(data, tmp_path): + save_pipe = dlt.pipeline(destination=filesystem(bucket_url=tmp_path.as_uri())) + saver = DltDestinationSaver(pipeline=save_pipe, table_name="test_table") + + metadata = saver.save_data(data) + + assert len(metadata["dlt_metadata"]["load_packages"]) == 1 + assert metadata["dlt_metadata"]["load_packages"][0]["state"] == "loaded" + assert Path(metadata["dlt_metadata"]["load_packages"][0]["jobs"][0]["file_path"]).exists() + + +def test_dlt_source_loader(): + resource = dlt.resource([{"a": 1, "b": 3}, {"a": 2, "b": 4}], name="mock_resource") + loader = DltResourceLoader(resource=resource) + + loaded_data, metadata = loader.load_data(pd.DataFrame) + + assert len(loaded_data) == len([row for row in resource]) + assert "_dlt_load_id" in loaded_data.columns From 7281cc47687a2aa7b36f49a35440244b007c4c80 Mon Sep 17 00:00:00 2001 From: zilto Date: Mon, 15 Apr 2024 17:03:36 -0400 Subject: [PATCH 6/7] updated docs --- docs/integrations/dlt/index.md | 113 +++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/docs/integrations/dlt/index.md b/docs/integrations/dlt/index.md index 67e57cfd2..61914f67d 100644 --- a/docs/integrations/dlt/index.md +++ b/docs/integrations/dlt/index.md @@ -8,6 +8,11 @@ On this page, you'll learn: - Extract, Transform, Load (ETL) - Extract, Load, Transform (ELT) +- dlt materializer plugin for Hamilton + +``` {note} +See this [blog post](https://blog.dagworks.io/p/slack-summary-pipeline-with-dlt-ibis) for a more detailed discussion about ETL with dlt + Hamilton +``` ## Extract, Transform, Load (ETL) The key consideration for ETL is that the data has to move twice: @@ -289,6 +294,114 @@ results = dr.execute( ) ``` +## dlt materializer plugin +We added custom Data Loader/Saver to plug dlt with Hamilton. Compared to the previous approach, it allows to include the dlt operations as part of the Hamilton dataflow and improve lineage / visibility. + + +``` {note} +See [this notebook](https://github.com/DAGWorks-Inc/hamilton/blob/main/examples/dlt/dlt_plugin.ipynb) for a demo. +``` + +### DataLoader +The `DataLoader` allows to read in-memory data from a `dlt.Resource`. When working with `dlt.Source`, you can access individual `dlt.Resource` with `source.resource["source_name"]`. This removes the need to write utility functions to read data from dlt (with pandas or Ibis). Contrary to the previous ETL and ELT examples, this approach is useful when you don't want to store the dlt Source data. It effectively connects dlt to Hamilton to enable "Extract, Transform" (ET). + + +```python +# run.py +from hamilton import driver +from hamilton.io.materialization import from_ +import slack # NOTE this is dlt code, not an official Slack library +import transform + +source = slack.source(selected_channels=["general"], replies=True) + +dr = driver.Builder().with_modules(transform).build() + +materializers = [ + from_.dlt( + target="general_messages", # node name assigned to the data + resource=source.resources["general_messages"] + ), + from_.dlt( + target="general_replies_message", + resource=source.resources["general_replies_message"] + ), +] + +dr.materialize(*materializers, ...) +``` + +### DataSaver +The `DataSaver` allows to write node results to any `dlt.Destination`. You'll need to define a `dlt.Pipeline` with the desired `dlt.Destination` and you can specify arguments for the `pipeline.run()` behavior (e.g., incremental loading, primary key, load_file_format). This provides a "Transform, Load" (TL) connector from Hamilton to dlt. + +```python +# run.py +from hamilton import driver +from hamilton.io.materialization import to +import slack # NOTE this is dlt code, not an official Slack library +import transform + +pipeline = dlt.pipeline( + pipeline_name="slack", + destination='duckdb', + dataset_name="slack_community_backup" +) + +dr = driver.Builder().with_modules(transform).build() + +materializers = [ + to.dlt( + id="threads__dlt", # node name + dependencies=["threads"], + table_name="slack_threads", + pipeline=pipeline, + ) +] + +dr.materialize(*materializers) +``` + +### Combining both +You can also combine both the `DataLoader` and `DataSaver`. You will see below that it's almost identical to the ELT example, but now all operations are part of the Hamilton dataflow! + + +```python +# run.py +from hamilton import driver +from hamilton.io.materialization import from_ +import slack # NOTE this is dlt code, not an official Slack library +import transform + +pipeline = dlt.pipeline( + pipeline_name="slack", + destination='duckdb', + dataset_name="slack_community_backup" +) +source = slack.source(selected_channels=["general"], replies=True) + +dr = driver.Builder().with_modules(transform).build() + +materializers = [ + from_.dlt( + target="general_messages", + resource=source.resources["general_messages"] + ), + from_.dlt( + target="general_replies_message", + resource=source.resources["general_replies_message"] + ), + to.dlt( + id="threads__dlt", + dependencies=["threads"], + table_name="slack_threads", + pipeline=pipeline, + ) +] + +dr.materialize(*materializers) +``` + + ## Next steps - Our full [code example to ingest Slack data and generate thread summaries](https://github.com/DAGWorks-Inc/hamilton/tree/main/examples/dlt) is available on GitHub. - Another important pattern in data engineering is reverse ETL, which consists of moving data analytics back to your sources (CRM, Hubspot, Zendesk, etc.). See this [dlt blog](https://dlthub.com/docs/blog/reverse-etl-dlt) to get started. From d4cd8fed4523c35560e48656437ca47a8530ebdd Mon Sep 17 00:00:00 2001 From: zilto Date: Tue, 16 Apr 2024 15:04:21 -0400 Subject: [PATCH 7/7] updated docs; added extension to registry --- docs/integrations/dlt/index.md | 30 +++++++++++++--------- docs/integrations/dlt/materialization.png | Bin 0 -> 47360 bytes docs/integrations/dlt/transform.png | Bin 0 -> 28389 bytes hamilton/function_modifiers/base.py | 1 + hamilton/plugins/dlt_extensions.py | 1 + 5 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 docs/integrations/dlt/materialization.png create mode 100644 docs/integrations/dlt/transform.png diff --git a/docs/integrations/dlt/index.md b/docs/integrations/dlt/index.md index 61914f67d..9f139d4d1 100644 --- a/docs/integrations/dlt/index.md +++ b/docs/integrations/dlt/index.md @@ -81,17 +81,19 @@ The key consideration for ETL is that the data has to move twice: return _table_to_df(client, "general_replies_message") def threads( - general_messages: pd.DataFrame, + general_message: pd.DataFrame, general_replies_message: pd.DataFrame, ) -> pd.DataFrame: """Reassemble from the union of parent messages and replies""" columns = ["thread_ts", "ts", "user", "text"] return pd.concat( - [general_messages[columns], general_replies_message[columns]], + [general_message[columns], general_replies_message[columns]], axis=0 ) ``` +![](transform.png) + 3. Add the Hamilton dataflow execution code to `run.py` ```python @@ -214,7 +216,7 @@ Transformations happen within the data destination, typically a data warehouse. ibis.set_backend(backend) return backend - def general_messages(db_con: ibis.BaseBackend, pipeline: dlt.Pipeline) -> ir.Table: + def general_message(db_con: ibis.BaseBackend, pipeline: dlt.Pipeline) -> ir.Table: """Load table `general_message` from dlt data""" return db_con.table( "general_message", @@ -234,13 +236,13 @@ Transformations happen within the data destination, typically a data warehouse. ) def threads( - general_messages: ir.Table, + general_message: ir.Table, general_replies_message: ir.Table, ) -> ir.Table: """Create the union of `general_message` and `general_replies_message`""" columns = ["thread_ts", "ts", "user", "text"] return ibis.union( - general_messages.select(columns), + general_message.select(columns), general_replies_message.select(columns), ) @@ -319,16 +321,17 @@ dr = driver.Builder().with_modules(transform).build() materializers = [ from_.dlt( - target="general_messages", # node name assigned to the data - resource=source.resources["general_messages"] + target="general_message", # node name assigned to the data + resource=source.resources["general_message"] ), from_.dlt( target="general_replies_message", resource=source.resources["general_replies_message"] ), ] - -dr.materialize(*materializers, ...) +# when using only loaders (i.e., `from_`), you need to specify +# `additional_vars` to compute, like you would in `.execute(final_vars=["threads"])` +dr.materialize(*materializers, additional_vars=["threads"]) ``` ### DataSaver @@ -336,6 +339,7 @@ The `DataSaver` allows to write node results to any `dlt.Destination`. You'll ne ```python # run.py +import dlt from hamilton import driver from hamilton.io.materialization import to import slack # NOTE this is dlt code, not an official Slack library @@ -367,8 +371,9 @@ You can also combine both the `DataLoader` and `DataSaver`. You will see below t ```python # run.py +import dlt from hamilton import driver -from hamilton.io.materialization import from_ +from hamilton.io.materialization import from_, to import slack # NOTE this is dlt code, not an official Slack library import transform @@ -383,8 +388,8 @@ dr = driver.Builder().with_modules(transform).build() materializers = [ from_.dlt( - target="general_messages", - resource=source.resources["general_messages"] + target="general_message", + resource=source.resources["general_message"] ), from_.dlt( target="general_replies_message", @@ -401,6 +406,7 @@ materializers = [ dr.materialize(*materializers) ``` +![](./materialization.png) ## Next steps - Our full [code example to ingest Slack data and generate thread summaries](https://github.com/DAGWorks-Inc/hamilton/tree/main/examples/dlt) is available on GitHub. diff --git a/docs/integrations/dlt/materialization.png b/docs/integrations/dlt/materialization.png new file mode 100644 index 0000000000000000000000000000000000000000..988461ac44e14a385326dea3e60441103fbfe11d GIT binary patch literal 47360 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 Am)uqRCal@%!KcHdwOI0JEkFMymITpg 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
  • Z8RJM`Zf>Eqkw$IF%$Zt$LyJ*` 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 b|MwZTpZ>fq(I_n(_$T&US~y2Y^TYoDlXu0r literal 0 HcmV?d00001 diff --git a/docs/integrations/dlt/transform.png b/docs/integrations/dlt/transform.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0e8df87666ca7c0a811b87ffda4a6f58094f25 GIT binary patch literal 28389 zcmce;WmuJ6xGp*oq`MmlLAs?wx*Mgt8!2h&5)h@OyHmPbX#@o6?vifKnBTY7S%1zt zd!2pl>r8(*nQx7F#uN8_k2gX^Ng55A7#RYApvlTes6imldLR&(a71|UOWxg~6Zj9_ zL|$3~@&x^t)mHcw0(k+Em3X7^A?;w%LkssE$@7!i^L!aRrf}q!j{jGGVRIA;nJ;&ip()$#UgpZpoK{i_8t@UmeyEXK3mh)KT)P)z5?d;R*g*uV>L z@8@qVq(g;-gs{lTSzJ-E!wU=Py1ToBW8Q%0Ay*8?pu%>>)2pwnth@k6KT1$x@jrvM zOf-dz=%2PTSpWBJN()+9F_NLdo}HgZA{r_C_nU*-HJH%8$fo!` z)7Pf^`g3JtBUs4$((I4l_9&S`Di>0N<8pr@vn~Yk^XJc<*=lNs#paie9uK!}=ev`1 z1BC~8dB+OxDh>J${?z_`eDH>dnwU`O>FJ$p{KUDwy&ar?Nk^9}9)^B1Dh5U~uZWjJ z53a@IxIaV48%zZ(sgM`0s;X*uY;0I%B`YzvO>ntxL-+DB1*FRF!E2$}4aIG5Dj+r% zV{L5>lbARXw7P3#q+G_=^1Yi|-;X$IEL_~+-d>68o13oTVbtxh%z?xChRs9ak@vUf z*1dgwtAE!At{?A?U55XCz7{G$Ky58&d_scrS}&68@e&z4JpA~tUt%rphbwz~$PixF z9ZX2)&PVrQYrCGK`~Dqo(!Ztgo$Vq)TFxqPd|W^Rko zG$e?2q%kWit7oE8I{zD8T@r5F-@X0OBooEzh1j0uP^%uK!tas9BqR(!KX=tAeT&Ax zz@YN>ZRnRT*zxi4M)vk-kPnv!M35IWG;o@wZxbA!G-fLFqozD)1q52`9+JO(gN1;B z*q9Ft3UV3w1%}GUKnP5q>dgROkL2gLI2eeGvhrA+qMTgh{TOxx&A{u-Wvh3|?J%MY z2^O|rJx9|ZF5pwhuj8>808dn7C$=|;}-E4Zkyp(5iToME}WwJe*zO&fk5p&r8 zi3k>Qy&6VQWjRI;W(I+b-}Uoxk_-(YmldX3B$6m&-IDVmA;C|{Rym|zP|JR z?{X%dVObh%UVi@fl1NbBf))d;LV^lv7z+yvMvjgczP`Ri#l>9<3#7hxmwf@S2+$u_ zdVH2@Eyqk28lBl*zYYxzg-?BLAK4Rzk!#TA1%debK8RiDs}K_t^Lk%0(JCZEaBy&} ztgKc_E8DxlEk3}KL6=&j#9OK3vM(|;PA)F$*I}Y&W@cI1wHA$T`}kDU)W%j;uUzvu z-Z1$;K_}`JMht3Fj`d`L@zv4dhud>{Y#bcW17==c-j%J9Z(wbkQbPTv#vrd{Bd(Co zHKy(FvY++(y1D`bhv0V!jT*4xB7Xxb!lIVy{PhcaZ*Q+itC|WPofOgE-=E82L2Ks& z^u(N=xK%Y^lgzvV0w`obP-}hciTgk0d;fQxcVgr?%wWlzAb4NrjqS%J&>MmfJ z9kZcO11Veo!G?#70&S%l9CnHVEe^kg5)C#Im_^{ORj3~B?(R%SQn@{2K7V18yFJbX z7a@KOvyu+&>F!?H-KBR$1(#A`f42HJ%7Enw9gF|hAoKjS)p(X5#UOMcX^@B46V}(& zg|xJ^jI%?>Ohz<^IaEeDo~iV(>}Ul|)4pPZjFsZoIL1}V@o z7Jx(G;GBd2$3KC!*Q62geJZEV{~|W*u1{VH3kz3)J{1(ueu|D}w-_drkdm^n?y#SK zEkpB#RJxAyicIhWCZx)BSM9s~JU--PQd#5;RossbXh*~QoFbaME*K6D4p#Q}qkwM) zA*ZLW@A<>+#ZdJ_zI@Vno$b5pyE|%ndZf6xxY>GplDRtDu-xWEym)Q2FrE zQbws5(ul0A7c{iA-D6`I78Vw~{*QtV%kAmqZDC;uX=!Pg!1n{6ktX6#jz-MY8H9jQ z1tOZ5d9sMVFpFLz1fo@Ah6Iw1`zUC;v`~|hS@fHYCJPj+zJCt}F%x(Kc9$&$$d775 zhl!l$?gz6l^78Ul*4Ekf^Yy$RPF`qeYDW3wQh-&9wfU*f38}iacBp;?I0XnM0YQ?z zVG@ge&B!J<50CWWTpboRwi*=?iY*9hIa-M^;EA}Iz^rt2bsJk+lC+-Gin-?NhfNw@ zP2$GK3*RT0gM~kz6d$j#;|H~nUKudM#l_Ys#EXKSr)Kq3Hz0C-IN!knPI+S6}+E+r=R?Ee0KywXr4ub{wW zr8D5v{bFw#76Q!(fb$erQ^U>>^b7=Uf&X1QV?yiod!_dh#<~21mpjm@{i@P~r``dDz zBAq%0;Lc&Gsi|+=D_%j@NpIXLN3PrcZ(jEULY?ncFvS|k;OHk}Ze$R#!|LnZ6YJ+2 z90xp>eG$aP#evW4?C*y!RL+zFQMu-#w5$wOSy}nJ^&~v#biNxOxTD18GwIMMsN?)2 zrPboLpSWMNpL7W$3c5D|Qcl6K(!#d_I%G@*C@$MvYItH(HCgZoCL{ZAc{8W#V@&-V`x zSorvf`+mU4l2cPpug5-o_^{!H3ht!pNi}CO3lb@GbaX@vjIp}tmX!{`)S6JExsW^71ud3f(A|oc`3}vKaNr^0Ac(8YmWUt@So5z{y(4PLjy(d zDM&y;qOx|eKNB7mg~PAZlwDDQ4q~~Mu5M3fr|1qZ^x=|JB__PIwD2q}EVCU?5a1KS zzklb-rcSU;tg~6&7xra)zaayzKp_hQ>$0d*LKT&cmeyptL{sZxzw+}d`fSa@ifiXS zey!i77~)^$KHFHMkX0|pD9U!VBwGo3ob8xGJe~yj2nf3@b7j)Q|2&;Z_ii{`k?^prpim=mIaFewCxX z42mjjJarfO`z?1uJciWHrFPDLN+@WL;~7I3L%}+a1(}lR<*|QPv$lNpdia-^3}O8H z&1c0GsQ-gkCZOUXvu0Gsdnf68ET^s*o;Igx3bR|{&YGZBsm??mX|43@u zAA`q_4z=GH+yX)+1%=*N5v2fMe*~rISJVn6Z+kWje;X|?wefl!rHwm-Hiy@!CYyZN z983t0j0l~KtqqP5%e4C4POal!H4q$ubMqMo3<-k^ZVq8gL_}9*#{>MpF&(IFtH+&p zICCvnMO9QVFAwMGTyep9IZWU@ci-mBz$DbBj?MUH%X!sbVYHVhwG z74>-8A5zZmwh7BU$krnKuzKu|1P=XM_kCf!>T`Pkp5y+3HCuKbEV^t~E4z7oIk~~s zIB4h?jDK&4y4>MUQCwVHRboQfd+32W*c@rvU71$f&X~&1=nFEJZBI?c*Xe4M@dv(8lmG*0Ch4I|MRPa%w-&# zX-C9kgw=m*ZUzr6)Tix{(xf7t$!F6{HeeW`?Us|hA$nw@)b-Q;2<{#p72TC9&;V^< zTbV+&bC(^Y6DhWB$J{(JiY~@X;I)fk=kEHc{S0fGI4h$%>8&>j`9gcZA0=4!EN5m^ zYi$Z!i>L6$hU!1Jz|Zmec9PY$lR*Zy+}NvR$rOlYVd)n1<1#WreZTn)3}-*UKwA8t zG&8D`66ilJY@-&^(Dk_W5Pv#04)a4BSsgE9*@|}RMH5+OIz;}O0vaoWZ(#W z@$SSA6CI3Va-)@G+^x{IFXrU~S@lI8#pN<`Kf??xFa?GqgSJoO2v-}2N}h*3A-Q4M zxrMP2QE!d5|1h4k-XPiv#pdj1QL3nF_N^S#nNDXsKt4LsjeWMf|0QUAyg1tEvChEo zwjwf<=Q#MT?rzre61hEv3-7Z`mD`P2Z)*gT#(%cWq%qHDx><;pMK5O1f;+1qJHSgZ z)wFZx80Lk%@vh4_4us8iBy;EJn)wsMyMRy)oVq=pUJQ9Z$%n0SWH-N#^(Bg1{RSlF zgM;kia@gEK&6AP5;vIe`M75=kflW?2l!SkVS+$Cgg%VA7&TRf|v5p%+7`g}Q+f1h=HxP;^Px~;OAqM2{UCp&ft&!biaU(x$&b?}0$ zW4=B9>Sq&qVL?Tl{ro`PXL-x=#lVU-J$w!*o|Hg~-{U#X&Gif-5gcYd?;)^a5_iYL z-5_z_($IK#eAVZkvQNH!74SlH*k(mvfs1^xDZzeObD0zJad6remx&47BQDUGJq>{~ zY7X*I+~2XUULm(exD##%(PUg&e`}#NjIDT_zgFqMyePV6fEk6@dgz;1wm@H@RJ1zKPXiQIy z*O(?zZPP(L*jVFt9dZUu&LI2kqej;~!Yx!1n&pbJvcMQajLsBsN!|BlF8pDV26ZW``t<;bjU|CFLO5sl*n#x~I?9azFemH$~wA>mRvL0$aFXDbg zwL_xGq~;EFf~PUmJvVHge{5XI*$BbVtEj&V(_Amms&}C%`-bO`AEqoDAJ68!XmMZl z0k4dh8#?=`bNoe%RqT%q=kK_#j|*PEG&diDb zP7S!!y1F_T$nn>&a7tpHvPudcV`5_dEy6YiecAPz-kU=@za_Ckk4D6N2$4%*K>DX# zmZ<>u1UYU9038C47lDuVee+(8s+&A43*>*EJW!~R;U#dZZ{NKOi;iY?(BowUZar}R z(eK9(nXvTq^l??tgmCCnP$3`yihmqKqIubi0s@TliyS}d@4rPR3lVl6xk9Z-JL}3M;SrORl>9ew(tf4DURGQj0Xn&M z?&on$dXCAD4io-?JalJz3Kp>9x~<5H2ql zKJW`^IXl%_({8cmKW@sWR%Aj5No3KF6!yI}>f3NeY<1V(;myG%APJIO52f6-XKH=A zk$#L9g_>QB)M}*h%a<yve^91zs19~hAec6rQnW^W>fD_yR4Lct{*PQj z4xA1F5Z%f7Nhbg-s~ko(?u8wRO>(J1V)Dm-O-`<~4G?j{`$yX(Fs|+;Fjm#q&Z%_NWYssLgnGNYoizw+Myqv+{>KF3za19Wjc`&IPC*} zYAucI?3i4+pbZtIzt?#ON?vgQ;8oj9GnCz4ylb?-C1VzFe=}K6W~5pv{E0y`R3pko z=Zq0V=hmo*{>iMcMwc7xdu8&zC`;w>77vOv{!NAC&>Pm!W$i()bWWy?jfDJljt7NPo#Xa7Ouq%6oSYoomEr2k*RQMF+hO}NmDF5ZxYgCwKYskc z1XGZJ#m~>r#?JmGS1Q`b(h>n+X<$whh$9qezao)Q(FMVyVe>yjvfG;~uBxua;+JXy zHLhlvVF$Uv-4!)EB}{)pY0mo#;_-?slnCr6U^Qs%9M-N~X|}{*+Mkx*4iF#D5w|4NEP+lgL(dW& z-D-UY#VGk{V8Cd$cVps`e%|*&jyL5Pj@gj zdwXI7R5|aDQjdx5g*_NbQr?DKi9BWL)Y*thQ!yw3`T+NhoE-A)5A+UH2j$FT`^` zkqdcIv9qg!4)z81v8nR819D(EQ-mVdl2sH%z&m>#GcIs)V!8*MzM`svnaHF~`!12oDrPl9%WU$~~IXGZ)#m%Am`4$y` zdZ0x=I!;fm=hNCh2*$qKSPxk{Jd7Tk9yYRGRM~kYL8Asd#A|)8mGRXcXbZ3t7Tg<~ znl?*=?(5h83L0R=5)1;!%V=>yYKIWOfgp?Nww=!mkBJEdjRD08^2>P}?4zS2Cs$WE zf|M5wEe!990}XomvyOjMi%F|m4O|{}_lLMZARxxhlAKg_Mj+EQ?5o7@Q?IsqUSLxn zq^_^^MFF@DR79%APTWX2r?2?=iJL>Epldd6lA8?<(&~98wL&8iGGLNiL@MX`M9xrB z^3PFqO?6lZ4ILdyT5!?H`*cnK6h@Zc8PT~?9MC}Fi8tnqp=6|_Q07YlKZoPxnac_Q zE}Xu6!6Ly!N~$Y$+B~4Aq3!L-aq#7R;KgySX4ew-OlwWQ3Ua#1G$$hB_5L6-N_yG7 zeitC~;R-#KAH@GGEH8AjxWl;d%rRNFHdm5654-*e-xjPpTI__9l< zKc@!Ub>k}CuKK=#kcXh;*+lso%*lvN*338-^u7U{e*Bh=Sxvz6JI$I4u#ny%jEReX zPD_S^HO0{=w~nE;pX;gc@F&#Z8ok=z2EpN5(ml=CWy3M|f!+g(Pa6gm_si$e_H#xD zt$l1I8bnEBX}#?tGpp7o)ZiMaDD?DIE>HM8ow@w?LeH(eAG({w+A!qT&*W&{k`mDl zhEOd_+_KOTPa^n7zcU`&n1`lUH_K>mwjcSPIMP6x*oPyv_B$U%yMCarf8{pBrO|Jd z^frbtXrh*2!)uAV*1z{p&)w#-2%1;8V$}8<+9A=(Z0e|OK@Os~N;l*%!gTG|#1K&( z8wO}z2{OgCU&W@^%fAdKJUdqeV;QUTIPeB+Z}-2;o(LQVp#!A(rUF=&OQ~T?TMrqSReeOeyW(HL?*t| zilXB@06m9 zSWR(wxsmK>=E(GSsz@zvY35whEx`@O+n~sPt)n%VO5HbFTdf#6{QQuQK>`hHYZ26Z z+n%XgT)fI=%#XD3jBp8~dt$JiB(=US2x^5h ztDS|{^H;?1pE4jTJ**-EZ_je94;u9t6@2qV0v)pDfcBWYGWYobVDtY zz3kyyDPOUj@!jerG*w0ZBGMl)A@d)i>wY zminnSmKOD5=_AKD@n-6j^cqQ_izBcdMrzCe(x&{D=&w-tl=FXT0fgRHb@HKnlh zev+N+eRF_h(19rZ+!uDraS7XWi>Wsr&lV+ZQ{8l%Ika6>a;iw3VA^&%&XK3wIv~K5 zR#aUV;WD$$!%z^7IbolEux|T8821z`1^hJHH#8jVj#P1k6m(BZHGa8Fcec}x;ij2n zXSb5PUvO|XmY>-D;UAMls0SX!DVD~ZTS`)ihxc`cI>t!Tp9$Z0D*HZ>gSm_HA;;6y zoN^d{HGlbiO5?-vp9o0iIwFYh-7O6=^72E!S++*@N$;2=(>U;EY;7#G1%^C_nQWm6z#E^<&48n}hCl%P1@#5}ukBAR!r9r1b;>(EA)<=UfEz|FINvm7SoYY@>J3{rp}^mu2cg(44>_w;Ds zB%YtL@sXDp>P_j=CHd*5%R_#I(WToyrw2SjVcmH}?}y?IoAgV^g-5te=17xMbyvEp z{ZFp_UYO)Ay$}*Y8w;dZ1uIog&=Z#gJu@_>!XRKQmt}gmdG+hnL?O>lbF(~xdn#eo z*?oGM`J8qP`F$|Tv>?z+^k?O;1LWW{=9TgH-M>Mi-}i6oeEi231^&daSC}B9DKg(+ zj9QmIiiQb4L<#B2rf-i75ojZSro<^+*~Pz!*B+PLNS`8U=$T6CXcb?@e)YHL&2m4xFA0s1*8eq*q*C+^M|>(MtPB zXSw}75=$muL~tW7D2mp>ldsA7!VUchgL+!rarr^ZrEh8x;$`Z-XBAWgfTf`IG&wA` z%^bPb{9A-zq^q>b@&f@*v^^vK&q5i9=v{`dJoC(0Zdf;fcm!L0ajI40R8-Z&`Yk8% zfBlMl+UjkM@4wh+us2=`Gru{L%X?K54NVAoCMHy~lM<)~F~BFAIzD+--Ew^^9|a1H z1$!KC>}N30Gv>PZBcz?oemhLYn%rK~mcewmyMqQId0L+&)8#s1E*7==FTAl}$#7Xu zlJngAz(9aw>Wk5ohk@vmwcq^8epym9Q<7VXpbty^>B#Pzy zMwvmgeQ#&0*N3Y(KZnPlteS8RT3cRFuax~C4MqeRlT5Jd`FZu+A?Bk&E%v+73^6q^ z!QuVi16B03CrsDbt&4>`O~tk{)vhg!qn;GKf1dFF#zsYl6vUvTDl&9|us-X06iQn@ zPe2dG*Yfw=$n4cNzaGCQ?nE3GH2N))hOX_en%0zK>FPxKlw%`Vlr1504$bklEt9c3 z8sye!qn+Pdd`L9^Ek`|5lkNaw4%F;VI1cDd3_3!=npRLK&6Xt*KqV9s9*TC%FV3qh zd|{7rCd@RH`Ho8RMV#+zxZybCoEl}SIHTWlidOS&9@STEuD`6{(uC0=RSv(g89lu5 z?)ZvM+RvwWu`_E1Tr4uJ4ppIxR{pX zxF@6MAIZhj;1pyeQm#E;GVnpy%ieBon4#WUF-X5cnqlN8E^0VNYDaj!7OZ_!CeIP2 z_SqukhYkqiIGt8|9#DRVfD%3&5kowb2^JnN+IYH=zM0P_>UKmInJf;F=wpnk zKS8-fqgJq$NGRYDH_kpt1`xc!jE;X&Ej_;{KE0ip%GR~*grW_n^nWS$a=X5&pdj%S zE!j1Y0e_aE(Q0(cHri%9M7_ZxB}I*p__8UluTQqMwMEF|K-%3S64QLb%|wiVfUrcS z2!u8(yc}f^Ajg1;W|(#BvjEqpjcmdo2(S)XKXw{W;C)fI*hb~C{jG2!md9lFL$OdP z-3e%BAfi4#f)#pAfq;Ibe)S5mpV?i0Id z{|gQd4*9#ZtyKK)KwBZ=c05}RTVG#4J~0sh*jZ5bRXMNs3raJc4Ps(qLIeZ^05#h+ zK8^`^1Zg0Msg`Y_am!M%P_z zP>sa^(G5ftaG&+|^Y9`fB6@9Jcyn`eeZRvLXoJ3f#koBA^L6uDz~jj1SDtL6@7?Qq zyFY40`;CCiwiwHZzB*opg#h`*>e^Z$opMI!%*?x`3K~u`IT@MG>C$)X=7YEpiUQh= zkV@k}*=1z_CwJ}b?KK)oWS*@uhE-5dSlQVL2S`x1;^s8{*{ z8geF>0ooe><;yb&n5|BrCz+_X*M)*ZK!PBlqVl+5}KhQM2;%L);JNx~8Y`AWl2u*<$+A zfGn@Fo}|^%(Gg=v$N?BqGM@`A#EgDJjzgSQ69n-hRan!3n7%sO86%t2t97Lt;zZw+ z5N9VRHou!qf~g995}*b9dBqwH<~6>-ZjxqUadCBLN6W+ozr}U;8CVkL-7%5k zhJ|dLnYeu6yM3$jFU6m;J84J*I%SP{XWMAp15ZOk6ECw2aRT<`Gv@JDRrQUR7e8RZ z1BQ}VbGLb@wGaXDjHe1l8JJZssOD-v)eHIE6ULGY1%`x3R$m&Mn}?9_IIQ$V5sXhy zGq*>*`dIUslFkS1tPphc<($8s-U30CF*i4NUS1wl;{)_|A;6%4a^OXrOjb(lH$?z$ z?hMKKj-DhizGJhQdIg7oKut*rJ1s9sAthP3M=4D8DKy1H7Djs?d?FtD8bk`iP{kw!6ge=J2TXy6OrE`a3>U$K6I zPLW8yFo&SiifChFW9Oegd_(R)a5kLEU8;3QDD1=QH9w{Ii|hlkmfl^9@ffP603ak-t>X$1m)l;2YuQV2~0 zPE!E*5+GoJqoAMw%{Bs34yIr*UUCrT(L5RdYx2H?KDYq@g5Bj9=CqN3Jz#1F2C zvGFpE4^ydb-GcAEL56CjSp)%$E0^sI3<$$Hf$aewkwk6<_qQ+M?@`Ev9@IcJukkwm z)&?6?^)FuUDb}xXfK>9>B**#q7O$~PKjN>@fz#7g9aZ}FmFMIHAlX0;)R%gpj(3g* zY%aCg`G0|{QpT|-6BdL2oc5mIUkS4F zbJc&GErfuqW{2o4)BE4`i{7RnWQQDu$;5?(g{_~g_5kq;s>4!q$P88`76k>JE7NT- z&_24lU8qd9(x!6TM^@;!PU^^@Xwvpfcu z<1!Lw=(*&NHh<^KDgrMwGSwBAy!`xC>v3@|o9Sk)8XX;;S85^>kvl+ic)eSg>AVrn zGnKE#W%q~pwa{(%cpf%a9mlh+(&9xnU+*aWJRKPXRi>@s6j*)z zMX`$^AW1I5Bwrq53MJWWGz4{kP$+88ZKegN6u))S^_}$0^Eh|+;9WWB_3aa^j!*px z$anKa%{ZYhBj$Myf!5bCM{Y0>DDeV#Op)`_?0|=cz_Z1%LT%=9k}XetkoKCc2mVQJ z@nfX<-03ne6)fBs7Rqd2?KD*JES;ye^3VQj@#P{a_lHNuz~iL=AQSkM`oO6(`$wGi z>uUJ^^2t&yj-x@lXf(eYyY&PzsKj{WD|)YaNtfzila$E&&c+A8k|>(b<7@xMj=^2N zx9O=bkr@>e)2}S@5Zv*2i<0;ojotJ@>igal9uyvu&h?3mj_jpp<+-o3X9Uuy0Dza7 zZv2Q-QDc~8G6D#iLm`~nkiW-EKN}VQftfV7Ap z(B<}E^NB=+{O+=;tEY#|%ggKLh)#F+;OJ^G&{u#fQfY zb}B=_9p<(A$7V{SL5=g;>)OihegNUlG0Z4wSrN*$eZgK^txG@4qJO$PT8u&`6Uec6 z2%U93C*{8&>uoMfo21`2DB1A+at?M+O&Rsm=U*TsB|IkMA$P zrf~Csy!ce4upn*a01DvB^e0$_TUQ+V8%hPNme{}nohTYfKy3i8XF?ZRh zd|TD($>URsx&8N=F{-edlQ2yvtQB6f= z`EP;eUsEnX5zTHf+ReJ<>m!0`ZEbC3zRWxz*7yQ+Zm7VefhmLyBGKlB1OXsgSu5t~ z`PL>1(C%xL-5W>BZfQh|Y>jnNoLTxX9hh_6WHRqfKdrf8!9bPOvQ z(h_pvP+Oui-NBd~Z?;t?d{>O@R!j@@v$Pl01M@qtc1V1+#F#gh^g+d)`v&D3(*yqZ z%;7ROVhd&&7wq$8(&g9s-WK!k#?LG+r(1ms?R*zI+{hr~IYGYlz8H?n64R;pQ6 zEbSKbQ~MMb$9*2hgnauqcStvmFC)gJEnTqhLA#aT1;0A*9V(3#Coi z$n8imS(2avu;=WR2ONav-ecVu00qD=h6;@x#_OuXC zjNZ0-aPSEv3y?zb>I=LMVD@1?}k8xaayBsb+k?hnzBF`XOjCIa$ zz_RMj*d^C|-(z=^Bjjq&qw^iH$~+FYr+#-xvd2A|NGAaZ-G-ov&mBCZbFUPmuL@)Y zr2S7Aqa7^JaJ-RhJ9_ND32e(?cXv0L-=28xYVY4VJPeM_?3V@KeD(CR zCA|_7R7p)1jNo01K-9VS&9t?PNYvFgqjsp6nGVCJ*`lGJ~DnPN7nc#kA6;9ir#gP0 z;;X%>aX=46=n6)4xJfB$;pOVRG9C0!RFF;}l5<3BN%h#)OfQT(>0hYtJ*K z*;$XFvpJd87*f8#@qt+O^Dt|`F$I80?wjcu0F`CA3=oV}M@wAhb9;nK40o;ea3}#T z=^5QmrT>CUmZBrVKXk54F{WZIZ zsp)$?vXI_F_gvaWzjdjYJFsXI#KHC!z~=fcYEtF=`kavgztZ#cbue{&T;(6dNif=J zgz268Pl^!XJWbf_b=*>@sLON8TU#CI>4Js*($McfI(FJGG9cvH={FQC5%G7<@@pBIdkEmEF>v8=}f-d$sm zjvSQux4(1mm68-2tI7d@h)iBZnotN}-Xk8d25Qscfz;Nj}J4`tNsuVrT6IY}$f(mj?@mR{9jmq6>-lRR zp@HhpP)qpOT4n#c0v6DLndJ}HnTbCrI5Rd0^@X%}Yys+zb6S7KV<-OgwY!#}F^z+M z^Jm-8cItkk^MmK7$1V(^B0w^9(vjbhbM~?8)E<;U?B*)062T%vzuwAf6Q$n?tOO4j z+|&=x_&$trI(IK}&zwQg*{FuYpKkunO-=_CWof6rO7aC>PE=^Bd^8Di>|pZ@G&^p_ za1V5Ut`FGqxF3xUT0yB>TU8AW1A)&`ptu!Ku9xXGVFR8Rz!z^y-Qd6qKy}jP%WTErM8QwAf1a!or7tc^Z1EY~s&w$Y5TuyoO)R zrkwQ|CeY!>tFqaHyZ7#+0Qk!B;i(Dl5WAx(7CCXCruSv;+n|1+CWeLBXxu)n!|yNe=pZhG{oijH3@xin8)GAnR*e* zuRp6yr`23{I3b`UfPCzjsrXFL4K!K9dTm%2-x_-D{YCEiAHqm0&lXl;A;P%BLr?C~ z=~9X(prNc8!QD^i?7bbn-#Q4I;Qqq%=e)OBYV{mf(d=15vjtVL1eMFv7WcImMQ?MR zZFX*M5ZK%Y-R1<{!2onLAo?RoOG^Xt8Xj!Ak^<#@7LfG62GlFiu$-KnbOOo9nQ!|{ zrQtfz{2-#DN&u*h-FZ!-l^Ir2Lse1dJ9Ys#bC#n9`w*gC?-|=oja%lo8XsmLH=|?? zo>WtJ{ zh<0}yoz5w4+K*zEbDH$&0NDV@M%~2wOtmCA7@rS_gF}|eCxF2Mp*73zERm4YlCq#o z>zi%CuWsN}-^)o$BgiMQbT$56pQt_-)tXW-<7SK~vV(H+-w4LUw@3AX>gQ6rdw*}Q zdufUM@{(e!sGzWL9pHhDeh>UC2Cd(!teekpBjBA{FW!mLDs zisPlWo^$~Zr@w2GV8^HN-?cudk^*d0YQNoMX1_S2CMFZqQ`rQ0jeDdyD5^q!a^t?Cy3$~97Pvv(z7}_K7r>@ZUYTh z$pFy+sOWe}0$QTdJvzwAMa4_#U74uVRJ?gA`T(uvbU(`uU{L(#Ok_NUEYM`D$pSQs zk<<_Xdo8qhV1P}#Cs$Wtv9Yl}=Z#X)!~n!hBIr)xa13ttx#1vwZ5Ix9xR`(~HheKm zNuhQqHu1l1;rcZdva^d{vbq+Q-@=mJo zOUUc=7QBP*C@iokB*6s~$-M7-NhVkgp%q8C9~q@94V4-juqq&OWxsULixS~+kN`rGV}Qh*IO{Xj+Ue08J`wuC{!Vkiy>woWGOR|A>5jG|&+jrovDp%Ph|pl6L% z?PgLzqEe#jeCPhV_vu%07L`BJhE{pW{1O7c?yEOV*5=H;Y zNd*}Mvial?K14J93*_GLR2gB$56XP3UkmceJ^?`LXNSmEJ;}SVn-`#qKrG=jXlocV z@F3{*N{^?k3uIl#7+(xOtpa_-ED$G!q7tbwY)?;5O9HrCrP@T2N$2~ku}e_*flWBlASGZo z>V&ugh!I!KFYjNyG&RuqLOz~V4z1kpb3krL%9$hoA6UZ{9W-LVv<%MYDS9uQoIQse zXw~5Y((pON)$N*gz!b)4HvUhe+fNLvv7XQIn6Z-UVX!|ut?Lv3UeSdnV%WI#zP7;` zPo2x&HtE=w8421a%#`QOxwpTQ_r^(d6CXZZdw`L%rNRycbO^v>e3pB}o7tZKrxpM` z+v9lUAcZskEu#P4>E@7%>d)m_eSl;`IzdqXN~a8evf728Z$4MDY&_XS0(eLHi#IfT z#A-~7N0Cs1`RnV$Y*4uX`?GZk#t=KNgxdWk}C-VuGzGaCeU}MOlszZZ|hAZimQxAYGFsX(5f(uAf9(9!ZOZ zXSOraXhM!p(9EKomblPy8+Y8`E&F?o016X6a$TM4aWtyuU%@76z*YdYD?%|55kNx- zL8%wqj+Ln)E#WCIuNd;QyUx4Q`n)T^gUw-qmr1(@?0kU_+#mv`3IKd+R##5oQQiPp z0+blqm%G4GPfg*f72|@r?Qy(A?<#Sg{1u~UKAc3}++~t1^QO)B4j%yh17HvR-%ER@ zJ+6CXH)5VQKyeM_*5xF%?lZ!1 z%iA)ofafUqE7yP5d7amQu&*Wbslj8}Su^n%c0fd0)T}r>k}9S%00&Ppk&z&OOM%mp zkI^N8lX2vSYT)fO*VSM|{^Noa3!Eetw^{fKvS`27d3o7?vp z+kO>yUPq%1ERILpelf>OU4WzG9H~L1jAS)6#bHQ&mnXa4kD4Zs1aN!Rd^tS(!VtA$ zGf+_Onalu~Wk#Mh_y_Zi6w==qPBykIz-pZDOo-1HBT@!S;UYf=V(1xn;x#fljBzB& zU`(-a6ZuGE=ods)J6U!n=oiXJX8O<&=og9Vmwi>xFTtwUoiU;aeMyxk~90o zL<{L1Io7vwHP7se!ku%qp& zA}Aeqi7c-Z1;e98X7mi-&V5hA?biOBVoWI!m!-h~-H!XN(qQxP;qFy}oP8mUL_0e> zJJ{Tw=5?_L$dSrJ*I;<6KvtNQg}>qjEIR0U1U(q*Yly4aKG>gsmj8R{Z0rN1PIyI#MK*S-CkMKFIyuct|m~odA z!+oW%uMg7PT|i_4*>3lghQ;oC4*NMSutf}%Qv85DEd*f>Y(9_Pn=VxcwCl5D5y4lO zA9c`3N@I6-cGN0z_(sV@@;vw56%J<7PqLnPYrGNkZ!snsV*x=`5f);yzmmq<^t){N zBzg^(2RgIgO+YI7DU&Ur6bp;XW>w-?)wIgmBuui7I60m^Dkr#X4f{BzH~7Pv!}6M( z%8P9!QscSvU4``5(Xu#n9M->RzzZ|#(oN#bt*oFg1Zi&GeJKxKt8kkJcFTfT0A)#S zZEZ_5OAA=ykC(?XJM^UCN+XTunZ}Z)X106$g07dZaGHGBAqEVg;;e8^PIt6{qv@+k zuDGF6v{PlvAC=ol!xT*gAby#A(Z1hiU|0OtUg_I0O|B<0^cikfZt#x|K|Azn;BKzN zjauF>t^4~^xoE(%u>|=>Vr+?Ae5GNn_BXkHn$-nNTA|iA$aY%SjPIVYnphY_WUKpi zwkvwa%f0mGDK{Obs9e(sW~Dz8JG zmEK@Dh+N0HlaAfFqK?PWXxa2PE&nxA0ocmw%N?R-H1ht*UOKpR0kA^@kAz6jm;vJQ z`a=yJO{5T|D$~4&MlNrO9*l5xr7{7@5UM}Ar^D!H7p)aZh!6U)RR?FK7kFLQd~8{EIc}hPb)(z%uzmy!1w zy#ugSwu^E(6``CC^#X_9=c)s*iVXvlrIlBf^)vL4?pf#Wym6pj2YCUwJ|ZB+cxWe9 z?x^hFwn>x@U(3_KiY+$NMr)YpA!KuUG2dXHE5UBS170i;(9pmG_L_r)_si_8*7^Cl zjFQr5O?A!cg6z%-wX?0N&qA)m;Ii>BFpKkn_=5E@AoocAzaQ5XriAxC^)6i zw`A|v?JBEFbSXXp6(sO8`7!qD=W>a|<5FLNeY5~8Iw|Hf_~s~a@)C%GoUD#h%_+-d zJ4&=-$n-dIyCKdECXgLrv*2dTx4>0Ii)CktZJxiREy*X2Do{3?IFDdO_Rd#(sC8?D zuWYMqg$7a8tkDdiq4Degr%#Oik$5qQ}`-^4^5*w-pFv=8E9ky<`7$e0SjSvRS= z665<68L%h9Rbf?~QCc&$GUf2mDh2uM{=c-oWC-Oy%`C4h6m#M+mt;8_g^Ohyol75j zeqcqPJl)kT3PE`OB^gs$+dg2&6%^ zZnd#9Y>Cq`>CP{P>v?f-aI;s)H=Mx_1GGwBmj}g%@u!+LHVl-Ml;C|ArIx<1&z=EN zGZ(z10%#)Gz>6HfOERpMTARF-x4?@o@L3JgC=E*>-Dewb2~UD_rDIAI|48{^19gBQkS-Q(yfIqsPXVY`5Jj^wG1mOY8Sc zOX?cXQU%PcO0CmQwgqHc`31fuFpkY!x3^MD7=WIa%>Q52eR(w0{ogmailRuem4pb9 z?35@=vXd^G7$-WNeeoxo$+~+yZdCqgr{pbB( zC(L~3`~7_0pZ9Bdzvhch=5r>()3|u;XXw-1iH2(mR^3Wt`>UjnzHJke>q0qdISk@& zL~g(1HA?< z9o>&AJENrJwBio0ko1GoDwGAJB@5~xKVDTq;-$t?M*du6RPtw5B<*S zH&(riC$o$oq`V<9|HS!uOeva)aEZUo5kZ47>F&PA$2+qV-h6!m89#45rZ!e~{qyfu zyE|L=r-=7otJR;=JMxP8?=@k!)W;eFbu13y)!Zv$YimrIRz(j5JN>0DFC-V4cE_cz zy|bU2)0qae1Y;yh;B1o5c=m7wi}@_^+RV%kTsN!+I*i!+tnyL+$OKV4ySNy>dD98W z9FUOoh;ZuSOL9^sR#u;le;(~lH5Bp*2+foROm9A(rm2q8)oQKLm-_bU)M!VRCx#fF zXc`7}GE(oU>04fyTkkNL72NF6DMFCRqc0calx)u+8*RDvk}KSKPawSA|Bis=s8H8- z!mq->!GUVcOtZaEHTxVcy8Fr*6vMHUhIWGjn{eP#o+L&wW)I3yy@L|>ieG#oyB z^q&pE>$ohCw<7q(x>A2kS;oIoS*LW%U2NHM{^aNmFuvRowz-hfK@F=zjivztW+3C_ zFMu+@o-d7+-*K4l1~u@*g{xbqfrimQj;fWKDuL-vj~$abR;n$Ns`~1caj=|@`c!F- zEQ_1$*zRJaMVFwDB-ENaIzlUMr`rnCtZ6zcPGl+fe3XJFKhTgP2&<}`W?q=z)2|h) ze!n1Ok(2X$ps0@>4uuJ>aDNh{H2CWs>CL99CHs#xi>*r7oj+-oHs~ag*1x?(#A1}I zGyIHkMR11{{$aXMb5+{c?=o(K&~!e!s3b?FHpQcaduscWBu66s1*R<>4Gk*6Hv_L` zk6Gbee?UE`i>ACXOE*zNS2{UC%WoR22q2>xC)1M5K9=>e*P0SA>9R0gidjX@|?Uh$~p!=W0wt`x|WaT0Bw{i9|xCOoMr` z2llHH-@yUqvw@p_LF~%(+=|Q)AVV1rBB|?RbL)|ll2o_PJ!umuTWJYp@Ve^Cd!2zH zd4{PH7GOkN+*3GwEk=q`U5AD{I?nU+mr8cXMM^#=Q8GRN>X&EphXyh(l4dFW1aI7O^3^R}$TFbe(eK^})0LL~3nup*I0}e7@x@ms1p0KDW=?wX3{IMqGOV$LjN4IZhRl4G4CsgxIJm2AN z!15_e3hAvt?XR0*6EET0>_g=ojc*SGns0emWShPzeNjWp$cP3f+n~aY&>Iist*r9k z0kyXJ;OR>^SuLyqu)i zkmI#`Zzpcukf=hh7PAbl>3fPiiH~UupY+;X|IHx%mjUz44;C^d#q7!6n4S~ASJRmu zzT*0N4z(Q%XT0+D+mPJXT;2-DK!3Ms*znkW#8%a}Jj!+qeS|r+dLf2jsl_q>?y*Md zmR$#f+p&lk=34o?-5qvItrT!w^qMr936#C>Ml_uTVBu?0iqu{x`#qvF)PwUea&wAE zijp>xrFr7Me`W5D=KM~B-^dpBOo2hQV)ccq#kS)xvU2+xk|LuUNujb0p`+4TeeFT%hl@e!v z{>0yQaIXFX)8U6h=-T=80=?1MLmDY${E>3LA9ccc?E0C?G-jxMmN5$^u$0rM-$=mb2w{3m9uSF*Ax6ePn zV6fD?5_!F_Kkival3n`71N-M5?F$>81-|Egc(jwmobDta9ptxPTh(s}tkUIG)GNE* zJ|1^0?m$mmY2Dc?E;P?p%quLq?(lJ2{Puyx9D=^`ySy?cJAXZq?PzDIna05VF{Ul# zMyG%bw`ZJp)qpmv|KI?1@ zTT;*}UbDIvIq8K~%|lwP9UBbz$iZWZulIcLpvP~f!Zhf}G3UW>!Ove!^*Z{vgC-B? z&P%Ouyh6^z#&HiP%55}HceghP)wG-A_ZJL~J$OF?3=%1O-&$aDZO>ae2e*oRm+Xk@ z>9ZV(eRP!YV{qG(ja5{17!#-anNj>Ex1i6(DMGF($G8aeerZUxU2=wm2P(`q6MtC< zC3iI{gjm_utW!u>+(< z=ip#8lraUSyi~w8{TY_pL*H>ez#ii7#!cn}O5E?MrU4H15^EM9u+pB&DJadgAaUwm zPHW?geoo2v1caKZ)y>9s+r`vlBa*sP8FU8`#KFl=rQBq96zE>9EJ~uL5V`2J@jCHw zq%1lqXwGdYE~YTdaDD@NL@@bob#_fMA-Q~2oIAO z7b;N%_U;R`oL!LzG~-VPt-|bV2Z7`+#zk8K)ZE;W`*^gSwsb{Dz~gnyW})vnGnbcz z#&UVfNatC7!CK;_v+7%=W)ng6+ifR{%S&pthVv({)gvNDw-;V0yD;43JHBxK^Nomz z2p6`M)5!-qZ5~%xcXx}kPg9OcQ#XU$ywI5`M`oWE#@%yniOaQbkGx+Tjb->6lcuO( z)Kq3m0M6cwPB$$9r+2C2VKZ^sIk|SaN+>nmX<;>I7?h`zu(_^4efq=*6$1!!z)3zW z4EU6U#?q|K?QOTU1Ur0Xmw6H3?zuQO$2@P6K^DSy-1ThJw=n5@0V5F$hRdkd<6$tr zY`uMOVuFm}e&&wBawx{;pK%nUb?OC8eI8#qQy8I^eZi&s%pWL{ua|| zV)L@PyF&{5ybjB#*cmmLy_OhiWau$1J#z#ZrJ}AowEQ55ROaJkq1PQ#mZK_im_j`l z+`kQQ3?d`ltablfojhzPUOrx?nL~wh%#wG1ysv@0o=%|J3<74hSmzO)IW{a5%Rz1CQ!H zy85by#Qj1sDw9{n(sC!yCXjR;(AY*TxRXlhqke&IG+H*23}_HeIGM^X$Rle@P7aO^ z`tt8xBGhy1iR}hJb{Ip0G!YVLyp{E^%7g#eI7h&lw_88;GcTUTX>se89DkOucT~!Z zpMiuA&;++1iZV?Jg@zthW_|7L{&`-VWJ9!RE&JLnG1o6s!WG`3ml$04W`6E{k1wFx zS`)DG_OojmP9(M~?mpVu<}w<7Gjb4Of5+bwzd>^pEMa7e=+$l=Lg(SS8NvPC!yU(1 z>hmsik#X0CMHW1Qp$#sz(*5!6*-4uBM1`P$P)GlNE*#F{Ql`Zm(6B>dE- zGT#=h>R21gXy4Cc(un>lWcLoO94h8@{u_pv{GI@BQ+FLDGzC9LnxZp%@~7D=KQ5b< zp!h0|kpy_|k;W}?E1DBCmL&m83SEY>QHzY_wbQM=x=!RK;xG^t*S<2*;6Pj-!OB~i zC$AnWv_mf>e`0B+G&XN?J!orR=~n|#umGmMZ(L@HCmAs&u+RE1=C+kd?+-%sy4k!_O>*%^_hvK55UV#CpKTMe%zwE`fnGDO+8%qTI5xbhCU#`|r z6n|lqq43pI4?s1y#5|?DcfU>q1hn8)|tm+W3#>uqi0gKXf?l*X*|xtZSBj{W?DzQI8g$m+#!JIG>mfKLY*nvD>l2nH{^ z9zy9<4z;wuO!9LL2Qe&o&p}WSDRjE7nE$*9Ld(W@I_Bcbjoo>mtPav&V^#2iy2s<^%f+|&DDhw17 zlf$h!{B#hz|G!GoUf^oN3<&cU9)*~+FexYK)=(0-VnlRuT z)mvLU7exK*;N~%!bVfA+r1Fsj#1AmV-~Bd!^Ev_G-6=1e2ld^TBwu)xD3~Si=`9fA z0tqki_Z>L_KfyVISFn(rWv>q!KM(=Qg9lKtu{1CuMoUls3T^9*UmGYZiSy6ynqJj{ggYhK@1W5>Ik)1yj2aAGKqL4K{^;0E9 zY-Xz74f0u1V5G{}TsurO8(idXVVS}75P8_w%A$7KzzI$@<`aV}fKCX!eSBaWa$JB%@1nKXx&clCI`9ZX>}?AQV(kMC>IH76;>-TMGw5^O^V_Ykbmc@h%mH9o40 zXL3MH62Kw`9}V6#MIgzOlrA%VDXQB~+XR1;&$6oXUWWm&tSqw+jw8iJC*Ks;2?#DZ z;lCpQrKpmjtD~b<>tub`0GU+C3mKZy@OlugdbSVOoPR`KyX<8)g?;nx6{sd1LF(e$PV&a-9K~V_eff5yG?!!V_#n%!2rLujvQE9)vbt# zJ}II!TIag>EzZVE8M6O2s&d|(JrjVf08WL>>v&VPV&UqAvp|^LEfP*XS=_Zh}dc;);r%VOsDW!mir-*+jH!HT%`; zf<1Lz_OGz3bW)pa?;QJaH}oJ58(O9+mu)tnT!I-M#DMX^QuG1b9_IZE^|X82+eR?l z2$^6$kZTSTb4x$ciDB{LhuRE`$QbTN!9-s#JbiEuNP(pd7@(I8p2@HY`@U-PK&aG( zWvw8^OFH4aRMi;?@x%T7M17<`p^8B!|6>RO2b!j*C`K3bYUf0%BfbFGK&23i51y{b znW~VGi};@p)54mWhX(}HX)!Q9WlCpxurWhF^}#ey^50jEj);Ic!n2^frWrJN3cMM3 zI?jP#p@*5hX1)+RHrn7N0TJ%Bw6v;7cNgDF44pub@CpgROG8bC5c?v>6|J#K+s`>U z9C&%AvojD(tlMy+_5(-tv1X7tQcBC1Ova|FrUp-I0u=^^y%->pm|7mlrn;NVEP{No z2z3$JFt`F`&3&VZ7$$1){)+ro_TTWQhN|pTihIxHyU0sYI%p(+>C;?OsIZxQkyq(L zl}2EV7T{m~^{WM5r7S#{LM9O6pJ7;YOj!!A;#Kz8jLEsgzLuCdd+T8YRS?)k{;2)n2Md5u3lh^?E<&o#-$OeWlHjpWd6f`WnD_VDTpbt*fj`Pbk- z1JE-k2RFnUg*|Hf`iBzeSkAq{Yi|1=9=^QqmcT*e)?X`U{iLSi+xnse0oW3Nv58S* ztVn$yIp&U)6E5&MXV$qQcWhL_rPm&J1H{!T>iWcid2H%QH9ch%=HYp!mX&rsSjX+3 zfDfm-&}vEA9jKX0K!IBfKN?v}40rDr#+-J^sUYn44lbWTCz*~S)1wNl@cp(9s;p=L zh+u7km6zRQ2IuPt8Z8Q?oYy7_#PtVjD%wtRU(4uST&T2_jfCOo#_yJwyXk}j=dhnk zJ!iyDCzo8G1!@_-gOwleY+gIc8 z9>y1>c3%j=`b2%IEk3TFCwI!%mFunMFI%1niN7OM;&DhhRE;+6Ij2G|@V=emL8YBW z^%?iq2$$+sP$4pm);*Weh977MH$>|YhLK*4t>r;ARfAuqV~y6POFr;;9{5nN-B(TX zzJ#|I0HBNmAvlFi+;C`FX~5bFmAsgEywgfyqaSp0PfOq`HYvNN^`0PXU=5y zrscSduOHznHB4)sNk;A=u$KL2b>XE0V;eijWlY4j}{vFkx>J~S)tbbip37s zK(=t=%Gc+rg^{w^C(qeg-@S`NI-n5S{RN^4>%Jx)1qZJkN?VJpADG-o>7E zqrqxjg@nnTx$71jPEs@I_h!zAGu3&2fPqqyE)fN6{NV6UE|fTtaZ3&Iu8fRK%6GrH z9IX=5v`dVAg%VoS=EguT_f<*W9CK&+$OGK|M=7^qE>c6KBh6O()t?Ee6zBYFuM$1l zXJQ%fDe9bzGY7BJRn9CPUN!E)=K>zgpuM`QjogV|M&z#uIy;S6|20q)a(l*-867_!@|r^=SXa-4Z5%f>4<*;~ z+nvr{-=>lDuM2w(2aiBlM3UmK=&pfmBPltLlO3b~J!&E-BTj#OYyXGbS2Ob5bhNE^ z#2d}#i6ZtO&i)+#{}ue!M+b-+LGGK#3MP7WG#g!JKFl!;wPC2Il`_CgR%v3(q6~sEgTqtD3OBXz3GQ+2~yK+bal;6N(g+J?5kOmh03XoA;#^%fKrj zl$xjG0=jb7M+U5%vizC$jZ9IR;k!Ewg1%eP+ykZGs0H%`0@FJMg%xf=c?ge5kWX%l ze_zlm;HAlx$aP`r)!?cv^#b&$&5iAGZ-&YTP_7qGJ~GDUHxK>rvA_BqZ~B;fGidkw zfJuT&H^|6s7`!_qO#1lt1D%|m%?@z`hoF;{M@Le~e1!gi0CGvtsTt1EU>PVuJ$vEV zE4a`m=|8;u+NcAD?m=}@=?U?nNEl18Fdc#L_6vqg1b~L1wnMM%3o3`Ou+KW6jped(-v6D-*yBO~Syt};#O@bPm=3qh;^z>)zcdfSZs@rt?G zrARooE%85pb9y@z;z+ztw<{Syy)*z7#)o2736fr6it!@y#JYG@kW2aoD_aYHl_S4t zZ8iQ|K!B!hspZ_pcT=00fLRYWntN7XmR>)^R#3Q<+IF3bvakR^s0y~tP31_~g=#lV z(WjtR;||`(X8lnOw{PB!S?fAQeF?b2D=0XKmr48a_le3M7;*mBEhIy==ovWl@o%MT zl8n9H8N=QDMJ}=|Ignyq3}0f-&SxP!C5huCEXbhT{rSBn@I0IzT3#ci;c5hw28z{Y z&!4w{)!p3R1O{BdZ2XNCID6=Va>FMcy9f2v=b z1kBmohx<$=c1x=ko~Liv^=h09q3Iq|mi2$lG9GIK5Z!YLax*qJH{A~hwWSS$UImZ4 z%XcDkB(q)CCVFPqZ};z}7g$XceV}ADE|B;C#*D?AgF*nw*SmQI>zI%}QAWr-eBcIV zOmD4+Afdr$#eW8T`X|_*@WerxH@mVi0v5@P#c%cY_J`j??b$OvF{f29~tc-IbF?m5B$);=!igbykH^o?~9^Pv^ z1k7`Z?X)<|-?YHD7r1QvL{_MS4G;{_v#YC$kgNh|{GU2iDU!86_g(;6IzS^&Kn*6p zA$C(~kCl3z0y12I{>jlUym7R(kxd~J0IDi}2Z1Y>coB|SxUZZ45A*I2jwt-56yI9x TJ@}S70s=W1W$6ORS0Dci_5`+7 literal 0 HcmV?d00001 diff --git a/hamilton/function_modifiers/base.py b/hamilton/function_modifiers/base.py index be3038443..2e8a7068f 100644 --- a/hamilton/function_modifiers/base.py +++ b/hamilton/function_modifiers/base.py @@ -36,6 +36,7 @@ "sklearn_plot", "vaex", "ibis", + "dlt", ] for plugin_module in plugins_modules: try: diff --git a/hamilton/plugins/dlt_extensions.py b/hamilton/plugins/dlt_extensions.py index 6ceb8854a..66525ba43 100644 --- a/hamilton/plugins/dlt_extensions.py +++ b/hamilton/plugins/dlt_extensions.py @@ -25,6 +25,7 @@ # convert to tuple to dynamically define type `Union[DATAFRAME_TYPES]` DATAFRAME_TYPES = tuple(DATAFRAME_TYPES) +COLUMN_FRIENDLY_DF_TYPE = False @dataclasses.dataclass