From 47c657a150931c6cbb649aea2cdfd27f12f50ba5 Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Mon, 1 Apr 2024 05:30:07 +0000 Subject: [PATCH 01/41] Updated synapse documentation --- .../website/docs/dlt-ecosystem/destinations/synapse.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index ff46efb272..e91928e700 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -95,6 +95,16 @@ pipeline = dlt.pipeline( dataset_name='chess_data' ) ``` +To use Active Directory Principal, you can use the `sqlalchemy.engine.URL.create` method to create the connection URL using your Active Directory Service Principal credentials. Once you have the connection URL, you can directly use it in your pipeline configuration or convert it to a string. +```py +pipeline = dlt.pipeline( + pipeline_name='chess', + destination=dlt.destinations.synapse( + credentials=connection_url.render_as_string(hide_password=False) + ), + dataset_name='chess_data' +) +``` ## Write disposition All write dispositions are supported. From 26fed1d85016e7012df9dc6a08bafb2a0969cfb7 Mon Sep 17 00:00:00 2001 From: Ilya Gurov Date: Wed, 10 Apr 2024 02:30:00 +0400 Subject: [PATCH 02/41] feat(transform): implement columns pivot map function (#1152) * feat(transform): implement columns pivot map function * add str test * support JSON paths * enumerate columns --------- Co-authored-by: Marcin Rudolf --- dlt/sources/helpers/transform.py | 90 +++++++++++++ tests/extract/test_transform.py | 181 ++++++++++++++++++++++++++ tests/pipeline/test_pipeline_state.py | 23 +--- 3 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 tests/extract/test_transform.py diff --git a/dlt/sources/helpers/transform.py b/dlt/sources/helpers/transform.py index 3949823be7..8d572f03ff 100644 --- a/dlt/sources/helpers/transform.py +++ b/dlt/sources/helpers/transform.py @@ -1,6 +1,11 @@ +from collections.abc import Sequence as C_Sequence +from typing import Any, Dict, Sequence, Union + from dlt.common.typing import TDataItem from dlt.extract.items import ItemTransformFunctionNoMeta +import jsonpath_ng + def take_first(max_items: int) -> ItemTransformFunctionNoMeta[bool]: """A filter that takes only first `max_items` from a resource""" @@ -24,3 +29,88 @@ def _filter(_: TDataItem) -> bool: return count > max_items return _filter + + +def pivot( + paths: Union[str, Sequence[str]] = "$", prefix: str = "col" +) -> ItemTransformFunctionNoMeta[TDataItem]: + """ + Pivot the given sequence of sequences into a sequence of dicts, + generating column names from the given prefix and indexes, e.g.: + {"field": [[1, 2]]} -> {"field": [{"prefix_0": 1, "prefix_1": 2}]} + + Args: + paths (Union[str, Sequence[str]]): JSON paths of the fields to pivot. + prefix (Optional[str]): Prefix to add to the column names. + + Returns: + ItemTransformFunctionNoMeta[TDataItem]: The transformer function. + """ + if isinstance(paths, str): + paths = [paths] + + def _seq_to_dict(seq: Sequence[Any]) -> Dict[str, Any]: + """ + Transform the given sequence into a dict, generating + columns with the given prefix. + + Args: + seq (List): The sequence to transform. + + Returns: + Dict: a dictionary with the sequence values. + """ + return {prefix + str(i): value for i, value in enumerate(seq)} + + def _raise_if_not_sequence(match: jsonpath_ng.jsonpath.DatumInContext) -> None: + """Check if the given field is a sequence of sequences. + + Args: + match (jsonpath_ng.jsonpath.DatumInContext): The field to check. + """ + if not isinstance(match.value, C_Sequence): + raise ValueError( + ( + "Pivot transformer is only applicable to sequences " + f"fields, however, the value of {str(match.full_path)}" + " is not a sequence." + ) + ) + + for item in match.value: + if not isinstance(item, C_Sequence): + raise ValueError( + ( + "Pivot transformer is only applicable to sequences, " + f"however, the value of {str(match.full_path)} " + "includes a non-sequence element." + ) + ) + + def _transformer(item: TDataItem) -> TDataItem: + """Pivot the given sequence item into a sequence of dicts. + + Args: + item (TDataItem): The data item to transform. + + Returns: + TDataItem: the data item with pivoted columns. + """ + for path in paths: + expr = jsonpath_ng.parse(path) + + for match in expr.find([item] if path in "$" else item): + trans_value = [] + _raise_if_not_sequence(match) + + for value in match.value: + trans_value.append(_seq_to_dict(value)) + + if path == "$": + item = trans_value + else: + match.full_path.update(item, trans_value) + + return item + + return _transformer diff --git a/tests/extract/test_transform.py b/tests/extract/test_transform.py new file mode 100644 index 0000000000..425e2a8fac --- /dev/null +++ b/tests/extract/test_transform.py @@ -0,0 +1,181 @@ +import pytest + +import dlt +from dlt.sources.helpers.transform import pivot +from dlt.extract.exceptions import ResourceExtractionError + + +@dlt.resource +def pivot_test_wrong_resource(): + yield [{"a": 1}] + + +@dlt.resource +def pivot_test_wrong_resource2(): + yield [{"a": [1]}] + + +def test_transform_pivot_wrong_data() -> None: + res = pivot_test_wrong_resource() + res.add_map(pivot("$.a")) + + with pytest.raises(ResourceExtractionError): + list(res) + + res2 = pivot_test_wrong_resource2() + res2.add_map(pivot("$.a")) + + with pytest.raises(ResourceExtractionError): + list(res) + + +@dlt.resource +def pivot_test_resource(): + for row in ( + [ + { + "a": { + "inner_1": [[1, 1, 1], [2, 2, 2]], + "inner_2": [[3, 3, 3], [4, 4, 4]], + "inner_3": [[5, 5, 5], [6, 6, 6]], + }, + "b": [7, 7, 7], + }, + { + "a": { + "inner_1": [[8, 8, 8], [9, 9, 9]], + "inner_2": [[10, 10, 10], [11, 11, 11]], + "inner_3": [[12, 12, 12], [13, 13, 13]], + }, + "b": [[14, 14, 14], [15, 15, 15]], + }, + ], + ): + yield row + + +def test_transform_pivot_single_path() -> None: + res = pivot_test_resource() + res.add_map(pivot("$.a.inner_1|inner_2")) + + result = list(res) + assert result == [ + { + "a": { + "inner_1": [{"col0": 1, "col1": 1, "col2": 1}, {"col0": 2, "col1": 2, "col2": 2}], + "inner_2": [{"col0": 3, "col1": 3, "col2": 3}, {"col0": 4, "col1": 4, "col2": 4}], + "inner_3": [[5, 5, 5], [6, 6, 6]], + }, + "b": [7, 7, 7], + }, + { + "a": { + "inner_1": [{"col0": 8, "col1": 8, "col2": 8}, {"col0": 9, "col1": 9, "col2": 9}], + "inner_2": [ + {"col0": 10, "col1": 10, "col2": 10}, + {"col0": 11, "col1": 11, "col2": 11}, + ], + "inner_3": [[12, 12, 12], [13, 13, 13]], + }, + "b": [[14, 14, 14], [15, 15, 15]], + }, + ] + + +def test_transform_pivot_multiple_paths() -> None: + res = pivot_test_resource() + res.add_map(pivot(["$.a.inner_1", "$.a.inner_2"])) + result = list(res) + assert result == [ + { + "a": { + "inner_1": [{"col0": 1, "col1": 1, "col2": 1}, {"col0": 2, "col1": 2, "col2": 2}], + "inner_2": [{"col0": 3, "col1": 3, "col2": 3}, {"col0": 4, "col1": 4, "col2": 4}], + "inner_3": [[5, 5, 5], [6, 6, 6]], + }, + "b": [7, 7, 7], + }, + { + "a": { + "inner_1": [{"col0": 8, "col1": 8, "col2": 8}, {"col0": 9, "col1": 9, "col2": 9}], + "inner_2": [ + {"col0": 10, "col1": 10, "col2": 10}, + {"col0": 11, "col1": 11, "col2": 11}, + ], + "inner_3": [[12, 12, 12], [13, 13, 13]], + }, + "b": [[14, 14, 14], [15, 15, 15]], + }, + ] + + +def test_transform_pivot_no_root() -> None: + res = pivot_test_resource() + res.add_map(pivot("a.inner_1")) + result = list(res) + assert result == [ + { + "a": { + "inner_1": [{"col0": 1, "col1": 1, "col2": 1}, {"col0": 2, "col1": 2, "col2": 2}], + "inner_2": [[3, 3, 3], [4, 4, 4]], + "inner_3": [[5, 5, 5], [6, 6, 6]], + }, + "b": [7, 7, 7], + }, + { + "a": { + "inner_1": [{"col0": 8, "col1": 8, "col2": 8}, {"col0": 9, "col1": 9, "col2": 9}], + "inner_2": [[10, 10, 10], [11, 11, 11]], + "inner_3": [[12, 12, 12], [13, 13, 13]], + }, + "b": [[14, 14, 14], [15, 15, 15]], + }, + ] + + +def test_transform_pivot_prefix() -> None: + res = pivot_test_resource() + res.add_map(pivot("$.a.inner_1", "prefix_")) + + result = list(res) + assert result == [ + { + "a": { + "inner_1": [ + {"prefix_0": 1, "prefix_1": 1, "prefix_2": 1}, + {"prefix_0": 2, "prefix_1": 2, "prefix_2": 2}, + ], + "inner_2": [[3, 3, 3], [4, 4, 4]], + "inner_3": [[5, 5, 5], [6, 6, 6]], + }, + "b": [7, 7, 7], + }, + { + "a": { + "inner_1": [ + {"prefix_0": 8, "prefix_1": 8, "prefix_2": 8}, + {"prefix_0": 9, "prefix_1": 9, "prefix_2": 9}, + ], + "inner_2": [[10, 10, 10], [11, 11, 11]], + "inner_3": [[12, 12, 12], [13, 13, 13]], + }, + "b": [[14, 14, 14], [15, 15, 15]], + }, + ] + + +@dlt.resource +def pivot_test_pandas_resource(): + import pandas + + df = pandas.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) + + yield df.values.tolist() + + +def test_pandas_dataframe_pivot() -> None: + res = pivot_test_pandas_resource() + res.add_map(pivot()) + + result = list(res) + assert result == [[{"col0": 1, "col1": 3, "col2": 5}], [{"col0": 2, "col1": 4, "col2": 6}]] diff --git a/tests/pipeline/test_pipeline_state.py b/tests/pipeline/test_pipeline_state.py index f0bcda2717..8cbc1ca516 100644 --- a/tests/pipeline/test_pipeline_state.py +++ b/tests/pipeline/test_pipeline_state.py @@ -4,7 +4,10 @@ import dlt -from dlt.common.exceptions import PipelineStateNotAvailable, ResourceNameNotAvailable +from dlt.common.exceptions import ( + PipelineStateNotAvailable, + ResourceNameNotAvailable, +) from dlt.common.schema import Schema from dlt.common.source import get_current_pipe_name from dlt.common.storages import FileStorage @@ -468,24 +471,6 @@ async def _gen_inner_rv_async_name(item, r_name): ) -def test_transform_function_state_write() -> None: - r = some_data_resource_state() - - # transform executed within the same thread - def transform(item): - dlt.current.resource_state()["form"] = item - return item * 2 - - r.add_map(transform) - assert list(r) == [2, 4, 6] - assert ( - state_module._last_full_state["sources"]["test_pipeline_state"]["resources"][ - "some_data_resource_state" - ]["form"] - == 3 - ) - - def test_migrate_pipeline_state(test_storage: FileStorage) -> None: # test generation of version hash on migration to v3 state_v1 = load_json_case("state/state.v1") From d53606c86dd81ba2a964daaf68d036da7a4cca0b Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Wed, 10 Apr 2024 05:30:44 +0000 Subject: [PATCH 03/41] Added code snippets --- .../dlt-ecosystem/destinations/synapse.md | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index e91928e700..d57ff4cab6 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -95,7 +95,27 @@ pipeline = dlt.pipeline( dataset_name='chess_data' ) ``` -To use Active Directory Principal, you can use the `sqlalchemy.engine.URL.create` method to create the connection URL using your Active Directory Service Principal credentials. Once you have the connection URL, you can directly use it in your pipeline configuration or convert it to a string. +To use **Active Directory Principal**, you can use the `sqlalchemy.engine.URL.create` method to create the connection URL using your Active Directory Service Principal credentials. First create the connection string as: +```py +conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};" + f"SERVER={server_name};" + f"DATABASE={database_name};" + f"UID={service_principal_id}@{tenant_id};" + f"PWD={service_principal_secret};" + f"Authentication=ActiveDirectoryServicePrincipal" +) +``` + +Next, create the connection URL: +```py +connection_url = URL.create( + "mssql+pyodbc", + query={"odbc_connect": conn_str} +) +``` + +Once you have the connection URL, you can directly use it in your pipeline configuration or convert it to a string. ```py pipeline = dlt.pipeline( pipeline_name='chess', From aabc3203a54145db5ae8b023e7653bef2ad52804 Mon Sep 17 00:00:00 2001 From: Anton Burnashev Date: Wed, 10 Apr 2024 16:58:33 +0300 Subject: [PATCH 04/41] Fix formatting (#1206) --- dlt/sources/helpers/transform.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/dlt/sources/helpers/transform.py b/dlt/sources/helpers/transform.py index 8d572f03ff..d038ec58ee 100644 --- a/dlt/sources/helpers/transform.py +++ b/dlt/sources/helpers/transform.py @@ -70,21 +70,17 @@ def _raise_if_not_sequence(match: jsonpath_ng.jsonpath.DatumInContext) -> None: """ if not isinstance(match.value, C_Sequence): raise ValueError( - ( - "Pivot transformer is only applicable to sequences " - f"fields, however, the value of {str(match.full_path)}" - " is not a sequence." - ) + "Pivot transformer is only applicable to sequences " + f"fields, however, the value of {str(match.full_path)}" + " is not a sequence." ) for item in match.value: if not isinstance(item, C_Sequence): raise ValueError( - ( - "Pivot transformer is only applicable to sequences, " - f"however, the value of {str(match.full_path)} " - "includes a non-sequence element." - ) + "Pivot transformer is only applicable to sequences, " + f"however, the value of {str(match.full_path)} " + "includes a non-sequence element." ) def _transformer(item: TDataItem) -> TDataItem: From d48942fc9dd3e88423c67138e1ee43bd5cad3172 Mon Sep 17 00:00:00 2001 From: Anton Burnashev Date: Thu, 11 Apr 2024 14:28:50 +0300 Subject: [PATCH 05/41] Import `Request` and `Response` directly from `requests` (#1210) --- dlt/sources/helpers/rest_client/detector.py | 2 +- dlt/sources/helpers/rest_client/paginators.py | 2 +- dlt/sources/helpers/rest_client/typing.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dlt/sources/helpers/rest_client/detector.py b/dlt/sources/helpers/rest_client/detector.py index 547162358c..136b73eba3 100644 --- a/dlt/sources/helpers/rest_client/detector.py +++ b/dlt/sources/helpers/rest_client/detector.py @@ -1,7 +1,7 @@ import re from typing import List, Dict, Any, Tuple, Union, Optional, Callable, Iterable -from dlt.sources.helpers.requests import Response +from requests import Response from .paginators import ( BasePaginator, diff --git a/dlt/sources/helpers/rest_client/paginators.py b/dlt/sources/helpers/rest_client/paginators.py index 11a28c22ea..01f4fa7296 100644 --- a/dlt/sources/helpers/rest_client/paginators.py +++ b/dlt/sources/helpers/rest_client/paginators.py @@ -2,7 +2,7 @@ from typing import Optional from urllib.parse import urlparse, urljoin -from dlt.sources.helpers.requests import Response, Request +from requests import Response, Request from dlt.common import jsonpath diff --git a/dlt/sources/helpers/rest_client/typing.py b/dlt/sources/helpers/rest_client/typing.py index 626aee4877..8042b1838c 100644 --- a/dlt/sources/helpers/rest_client/typing.py +++ b/dlt/sources/helpers/rest_client/typing.py @@ -6,7 +6,7 @@ Callable, Any, ) -from dlt.sources.helpers.requests import Response +from requests import Response HTTPMethodBasic = Literal["GET", "POST"] From 1c01821eba3864431e37ecdfe17c92bf2217ee7f Mon Sep 17 00:00:00 2001 From: Anton Burnashev Date: Thu, 11 Apr 2024 14:48:17 +0300 Subject: [PATCH 06/41] Import `Request` and `Response` directly from `requests` in client.py (#1211) --- dlt/sources/helpers/rest_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlt/sources/helpers/rest_client/client.py b/dlt/sources/helpers/rest_client/client.py index 027afc7cbb..1c3e4d97b6 100644 --- a/dlt/sources/helpers/rest_client/client.py +++ b/dlt/sources/helpers/rest_client/client.py @@ -12,11 +12,11 @@ from urllib.parse import urlparse from requests import Session as BaseSession # noqa: I251 +from requests import Response, Request from dlt.common import logger from dlt.common import jsonpath from dlt.sources.helpers.requests.retry import Client -from dlt.sources.helpers.requests import Response, Request from .typing import HTTPMethodBasic, HTTPMethod, Hooks from .paginators import BasePaginator From 05aa413db8d4819c855b6b1ba0c4315c2c197040 Mon Sep 17 00:00:00 2001 From: Jorrit Sandbrink <47451109+jorritsandbrink@users.noreply.github.com> Date: Sun, 14 Apr 2024 15:40:55 +0400 Subject: [PATCH 07/41] SCD2 support (#1168) * format examples * add core functionality for scd2 merge strategy * make scd2 validity column names configurable * make alias descriptive * add validity column name conflict checking * extend write disposition with dictionary configuration option * add default delete-insert merge strategy * update write_disposition type hints * extend tested destinations * 2nd time setup (#1202) * remove obsolete deepcopy * add scd2 docs * add write_disposition existence condition * add nullability hints to validity columns * cache functions to limit schema lookups * add row_hash_column_name config option * default to default merge strategy * replace hardcoded column name with variable to fix test * fix doc snippets * compares records without order and with caps timestamps precision in scd2 tests * defines create load id, stores package state typed, allows package state to be passed on, uses load_id as created_at if possible * creates new package to normalize from extracted package so state is carried on * bans direct pendulum import * uses timestamps with properly reduced precision in scd2 * selects newest state by load_id, not created_at. this will not affect execution as long as packages are processed in order * adds formating datetime literal to escape * renames x-row-hash to x-row-version * corrects json and pendulum imports * uses unique column in scd2 sql generation * renames arrow items literal * adds limitations to docs * passes only complete columns to arrow normalize * renames mode to disposition * saves parquet with timestamp precision corresponding to the destination and updates schema in the normalizer * adds transform that computes hashes of tables * tests arrow/pandas + scd2 * allows scd2 columns to be added to arrow items * various renames * uses generic caps when writing parquet if no destination context * disables coercing timestamps in parquet arrow writer --------- Co-authored-by: Jorrit Sandbrink Co-authored-by: adrianbr Co-authored-by: rudolfix --- README.md | 2 +- dlt/cli/_dlt.py | 2 +- dlt/cli/config_toml_writer.py | 2 +- dlt/cli/pipeline_command.py | 2 +- dlt/cli/utils.py | 3 - .../configuration/providers/google_secrets.py | 3 +- dlt/common/configuration/providers/toml.py | 2 +- .../configuration/specs/azure_credentials.py | 3 +- .../configuration/specs/gcp_credentials.py | 3 +- dlt/common/configuration/utils.py | 4 +- dlt/common/data_types/type_helpers.py | 6 +- dlt/common/data_writers/escape.py | 16 + dlt/common/data_writers/writers.py | 21 +- dlt/common/destination/reference.py | 7 + dlt/common/json/__init__.py | 8 +- dlt/common/libs/numpy.py | 2 +- dlt/common/libs/pandas.py | 2 +- dlt/common/libs/pyarrow.py | 10 +- dlt/common/normalizers/json/relational.py | 70 ++- dlt/common/pipeline.py | 11 +- dlt/common/runners/pool_runner.py | 3 +- dlt/common/runtime/prometheus.py | 1 - dlt/common/runtime/segment.py | 2 - dlt/common/runtime/slack.py | 3 +- dlt/common/schema/detections.py | 3 +- dlt/common/schema/exceptions.py | 4 + dlt/common/schema/schema.py | 1 - dlt/common/schema/typing.py | 26 +- dlt/common/schema/utils.py | 11 +- dlt/common/storages/__init__.py | 2 + dlt/common/storages/fsspec_filesystem.py | 2 +- dlt/common/storages/fsspecs/google_drive.py | 2 +- dlt/common/storages/load_package.py | 45 +- dlt/common/storages/load_storage.py | 10 +- dlt/common/storages/schema_storage.py | 3 +- dlt/common/time.py | 10 +- dlt/common/utils.py | 2 +- dlt/common/versioned_state.py | 30 +- dlt/destinations/decorators.py | 7 +- dlt/destinations/impl/bigquery/bigquery.py | 3 +- .../impl/databricks/sql_client.py | 2 - dlt/destinations/impl/destination/factory.py | 5 +- dlt/destinations/impl/dummy/dummy.py | 2 +- dlt/destinations/impl/qdrant/qdrant_client.py | 4 +- .../impl/weaviate/weaviate_client.py | 7 +- dlt/destinations/job_client_impl.py | 6 +- dlt/destinations/job_impl.py | 2 +- dlt/destinations/path_utils.py | 3 +- dlt/destinations/sql_jobs.py | 130 +++- dlt/extract/decorators.py | 16 +- dlt/extract/extract.py | 4 +- dlt/extract/extractors.py | 30 +- dlt/extract/hints.py | 110 +++- dlt/extract/incremental/__init__.py | 3 +- dlt/extract/incremental/transform.py | 2 +- dlt/extract/storage.py | 6 +- dlt/helpers/airflow_helper.py | 18 +- dlt/helpers/dbt/dbt_utils.py | 3 +- dlt/helpers/streamlit_app/__init__.py | 4 +- dlt/helpers/streamlit_app/blocks/load_info.py | 2 +- dlt/helpers/streamlit_app/blocks/query.py | 4 +- .../streamlit_app/blocks/resource_state.py | 6 +- dlt/load/load.py | 3 +- dlt/normalize/items_normalizers.py | 28 +- dlt/normalize/normalize.py | 11 +- dlt/pipeline/__init__.py | 8 +- dlt/pipeline/pipeline.py | 17 +- dlt/pipeline/platform.py | 7 +- dlt/pipeline/state_sync.py | 30 +- dlt/pipeline/trace.py | 2 +- dlt/pipeline/track.py | 3 +- dlt/sources/helpers/rest_client/auth.py | 8 +- dlt/sources/helpers/rest_client/client.py | 4 +- dlt/sources/helpers/transform.py | 34 + dlt/version.py | 2 +- docs/examples/nested_data/nested_data.py | 2 +- docs/tools/lint_setup/template.py | 5 +- .../2024-03-11-moving-away-from-segment.md | 22 +- .../blog/2024-03-25-reverse_etl_dlt.md | 2 +- .../website/blog/2024-03-26-second-data-setup | 123 ++++ .../docs/dlt-ecosystem/destinations/athena.md | 2 +- .../dlt-ecosystem/destinations/bigquery.md | 2 +- .../dlt-ecosystem/destinations/databricks.md | 2 +- .../docs/dlt-ecosystem/destinations/dremio.md | 4 +- .../docs/dlt-ecosystem/destinations/duckdb.md | 2 +- .../dlt-ecosystem/destinations/filesystem.md | 2 +- .../dlt-ecosystem/destinations/motherduck.md | 2 +- .../docs/dlt-ecosystem/destinations/mssql.md | 2 +- .../dlt-ecosystem/destinations/postgres.md | 2 +- .../docs/dlt-ecosystem/destinations/qdrant.md | 2 +- .../dlt-ecosystem/destinations/redshift.md | 2 +- .../dlt-ecosystem/destinations/snowflake.md | 2 +- .../dlt-ecosystem/destinations/synapse.md | 2 +- .../verified-sources/google_sheets.md | 2 - .../dlt-ecosystem/verified-sources/strapi.md | 2 +- .../customising-pipelines/removing_columns.md | 2 +- .../docs/general-usage/incremental-loading.md | 150 ++++- .../deploy-gcp-cloud-function-as-webhook.md | 31 +- tests/cases.py | 2 +- tests/common/storages/test_load_package.py | 45 +- tests/common/storages/test_load_storage.py | 21 + tests/extract/test_sources.py | 22 + tests/load/pipeline/test_arrow_loading.py | 9 +- tests/load/pipeline/test_merge_disposition.py | 17 + tests/load/pipeline/test_scd2.py | 583 ++++++++++++++++++ tests/pipeline/test_arrow_sources.py | 26 +- tests/pipeline/utils.py | 1 + tests/utils.py | 6 +- tox.ini | 1 + 109 files changed, 1663 insertions(+), 314 deletions(-) create mode 100644 docs/website/blog/2024-03-26-second-data-setup create mode 100644 tests/load/pipeline/test_scd2.py diff --git a/README.md b/README.md index 5cb681c570..09bfab0b12 100644 --- a/README.md +++ b/README.md @@ -113,4 +113,4 @@ The dlt project is quickly growing, and we're excited to have you join our commu ## License -DLT is released under the [Apache 2.0 License](LICENSE.txt). +`dlt` is released under the [Apache 2.0 License](LICENSE.txt). diff --git a/dlt/cli/_dlt.py b/dlt/cli/_dlt.py index 2332c0286c..af4f2f66e9 100644 --- a/dlt/cli/_dlt.py +++ b/dlt/cli/_dlt.py @@ -5,7 +5,7 @@ import click from dlt.version import __version__ -from dlt.common import json +from dlt.common.json import json from dlt.common.schema import Schema from dlt.common.typing import DictStrAny from dlt.common.runners import Venv diff --git a/dlt/cli/config_toml_writer.py b/dlt/cli/config_toml_writer.py index 8cf831d725..7ff7f735eb 100644 --- a/dlt/cli/config_toml_writer.py +++ b/dlt/cli/config_toml_writer.py @@ -4,7 +4,7 @@ from tomlkit.container import Container as TOMLContainer from collections.abc import Sequence as C_Sequence -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.configuration.specs import ( BaseConfiguration, is_base_configuration_inner_hint, diff --git a/dlt/cli/pipeline_command.py b/dlt/cli/pipeline_command.py index 0eb73ad7a8..d66d884ff2 100644 --- a/dlt/cli/pipeline_command.py +++ b/dlt/cli/pipeline_command.py @@ -3,7 +3,7 @@ import dlt from dlt.cli.exceptions import CliCommandException -from dlt.common import json +from dlt.common.json import json from dlt.common.pipeline import resource_state, get_dlt_pipelines_dir, TSourceState from dlt.common.destination.reference import TDestinationReferenceArg from dlt.common.runners import Venv diff --git a/dlt/cli/utils.py b/dlt/cli/utils.py index 5ea4471d7e..8699116628 100644 --- a/dlt/cli/utils.py +++ b/dlt/cli/utils.py @@ -1,11 +1,8 @@ import ast import os -import tempfile from typing import Callable -from dlt.common import git from dlt.common.reflection.utils import set_ast_parents -from dlt.common.storages import FileStorage from dlt.common.typing import TFun from dlt.common.configuration import resolve_configuration from dlt.common.configuration.specs import RunConfiguration diff --git a/dlt/common/configuration/providers/google_secrets.py b/dlt/common/configuration/providers/google_secrets.py index 98cbbc4553..43a284c67c 100644 --- a/dlt/common/configuration/providers/google_secrets.py +++ b/dlt/common/configuration/providers/google_secrets.py @@ -1,9 +1,8 @@ import base64 import string import re -from typing import Tuple -from dlt.common import json +from dlt.common.json import json from dlt.common.configuration.specs import GcpServiceAccountCredentials from dlt.common.exceptions import MissingDependencyException from .toml import VaultTomlProvider diff --git a/dlt/common/configuration/providers/toml.py b/dlt/common/configuration/providers/toml.py index 7c856e8c27..10e0b470de 100644 --- a/dlt/common/configuration/providers/toml.py +++ b/dlt/common/configuration/providers/toml.py @@ -6,7 +6,7 @@ from tomlkit.container import Container as TOMLContainer from typing import Any, Dict, Optional, Tuple, Type, Union -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.configuration.paths import get_dlt_settings_dir, get_dlt_data_dir from dlt.common.configuration.utils import auto_cast from dlt.common.configuration.specs import known_sections diff --git a/dlt/common/configuration/specs/azure_credentials.py b/dlt/common/configuration/specs/azure_credentials.py index f7cac78dca..52d33ec0d3 100644 --- a/dlt/common/configuration/specs/azure_credentials.py +++ b/dlt/common/configuration/specs/azure_credentials.py @@ -1,7 +1,6 @@ from typing import Optional, Dict, Any -from dlt.common import pendulum -from dlt.common.exceptions import MissingDependencyException +from dlt.common.pendulum import pendulum from dlt.common.typing import TSecretStrValue from dlt.common.configuration.specs import ( CredentialsConfiguration, diff --git a/dlt/common/configuration/specs/gcp_credentials.py b/dlt/common/configuration/specs/gcp_credentials.py index 4d81a493a3..9927b81ebf 100644 --- a/dlt/common/configuration/specs/gcp_credentials.py +++ b/dlt/common/configuration/specs/gcp_credentials.py @@ -2,7 +2,8 @@ import sys from typing import Any, ClassVar, Final, List, Tuple, Union, Dict -from dlt.common import json, pendulum +from dlt.common.json import json +from dlt.common.pendulum import pendulum from dlt.common.configuration.specs.api_credentials import OAuth2Credentials from dlt.common.configuration.specs.exceptions import ( InvalidGoogleNativeCredentialsType, diff --git a/dlt/common/configuration/utils.py b/dlt/common/configuration/utils.py index 5a7330447b..51e6b5615a 100644 --- a/dlt/common/configuration/utils.py +++ b/dlt/common/configuration/utils.py @@ -5,7 +5,7 @@ from typing import Any, Dict, Mapping, NamedTuple, Optional, Tuple, Type, Sequence from collections.abc import Mapping as C_Mapping -from dlt.common import json +from dlt.common.json import json from dlt.common.typing import AnyType, TAny from dlt.common.data_types import coerce_value, py_type_to_sc_type from dlt.common.configuration.providers import EnvironProvider @@ -122,8 +122,6 @@ def log_traces( default_value: Any, traces: Sequence[LookupTrace], ) -> None: - from dlt.common import logger - # if logger.is_logging() and logger.log_level() == "DEBUG" and config: # logger.debug(f"Field {key} with type {hint} in {type(config).__name__} {'NOT RESOLVED' if value is None else 'RESOLVED'}") # print(f"Field {key} with type {hint} in {type(config).__name__} {'NOT RESOLVED' if value is None else 'RESOLVED'}") diff --git a/dlt/common/data_types/type_helpers.py b/dlt/common/data_types/type_helpers.py index 61a0aa1dbf..d8ab9eb118 100644 --- a/dlt/common/data_types/type_helpers.py +++ b/dlt/common/data_types/type_helpers.py @@ -3,13 +3,13 @@ import dataclasses import datetime # noqa: I251 from collections.abc import Mapping as C_Mapping, Sequence as C_Sequence -from typing import Any, Type, Literal, Union, cast +from typing import Any, Type, Union from enum import Enum -from dlt.common import pendulum, json, Decimal, Wei from dlt.common.json import custom_pua_remove, json from dlt.common.json._simplejson import custom_encode as json_custom_encode -from dlt.common.arithmetics import InvalidOperation +from dlt.common.wei import Wei +from dlt.common.arithmetics import InvalidOperation, Decimal from dlt.common.data_types.typing import TDataType from dlt.common.time import ( ensure_pendulum_datetime, diff --git a/dlt/common/data_writers/escape.py b/dlt/common/data_writers/escape.py index 3200350f0b..e812afdaf1 100644 --- a/dlt/common/data_writers/escape.py +++ b/dlt/common/data_writers/escape.py @@ -4,6 +4,8 @@ from datetime import date, datetime, time # noqa: I251 from dlt.common.json import json +from dlt.common.pendulum import pendulum +from dlt.common.time import reduce_pendulum_datetime_precision # use regex to escape characters in single pass SQL_ESCAPE_DICT = {"'": "''", "\\": "\\\\", "\n": "\\n", "\r": "\\r"} @@ -152,3 +154,17 @@ def escape_databricks_literal(v: Any) -> Any: return "NULL" return str(v) + + +def format_datetime_literal(v: pendulum.DateTime, precision: int = 6, no_tz: bool = False) -> str: + """Converts `v` to ISO string, optionally without timezone spec (in UTC) and with given `precision`""" + if no_tz: + v = v.in_timezone(tz="UTC").replace(tzinfo=None) + v = reduce_pendulum_datetime_precision(v, precision) + # yet another precision translation + timespec: str = "microseconds" + if precision < 6: + timespec = "milliseconds" + elif precision < 3: + timespec = "seconds" + return v.isoformat(sep=" ", timespec=timespec) diff --git a/dlt/common/data_writers/writers.py b/dlt/common/data_writers/writers.py index b952b39ed2..60457f103e 100644 --- a/dlt/common/data_writers/writers.py +++ b/dlt/common/data_writers/writers.py @@ -17,7 +17,7 @@ TypeVar, ) -from dlt.common import json +from dlt.common.json import json from dlt.common.configuration import configspec, known_sections, with_config from dlt.common.configuration.specs import BaseConfiguration from dlt.common.data_writers.exceptions import DataWriterNotFound, InvalidDataItem @@ -176,6 +176,9 @@ def writer_spec(cls) -> FileWriterSpec: class InsertValuesWriter(DataWriter): def __init__(self, f: IO[Any], caps: DestinationCapabilitiesContext = None) -> None: + assert ( + caps is not None + ), "InsertValuesWriter requires destination capabilities to be present" super().__init__(f, caps) self._chunks_written = 0 self._headers_lookup: Dict[str, int] = None @@ -272,7 +275,7 @@ def __init__( coerce_timestamps: Optional[Literal["s", "ms", "us", "ns"]] = None, allow_truncated_timestamps: bool = False, ) -> None: - super().__init__(f, caps) + super().__init__(f, caps or DestinationCapabilitiesContext.generic_capabilities("parquet")) from dlt.common.libs.pyarrow import pyarrow self.writer: Optional[pyarrow.parquet.ParquetWriter] = None @@ -287,7 +290,15 @@ def __init__( self.allow_truncated_timestamps = allow_truncated_timestamps def _create_writer(self, schema: "pa.Schema") -> "pa.parquet.ParquetWriter": - from dlt.common.libs.pyarrow import pyarrow + from dlt.common.libs.pyarrow import pyarrow, get_py_arrow_timestamp + + # if timestamps are not explicitly coerced, use destination resolution + # TODO: introduce maximum timestamp resolution, using timestamp_precision too aggressive + # if not self.coerce_timestamps: + # self.coerce_timestamps = get_py_arrow_timestamp( + # self._caps.timestamp_precision, "UTC" + # ).unit + # self.allow_truncated_timestamps = True return pyarrow.parquet.ParquetWriter( self._f, @@ -331,7 +342,9 @@ def write_data(self, rows: Sequence[Any]) -> None: for key in self.complex_indices: for row in rows: if (value := row.get(key)) is not None: - row[key] = json.dumps(value) + # TODO: make this configurable + if value is not None and not isinstance(value, str): + row[key] = json.dumps(value) table = pyarrow.Table.from_pylist(rows, schema=self.schema) # Write diff --git a/dlt/common/destination/reference.py b/dlt/common/destination/reference.py index ddcc5d1146..9318dca535 100644 --- a/dlt/common/destination/reference.py +++ b/dlt/common/destination/reference.py @@ -26,6 +26,7 @@ from dlt.common import logger from dlt.common.schema import Schema, TTableSchema, TSchemaTables +from dlt.common.schema.typing import MERGE_STRATEGIES from dlt.common.schema.exceptions import SchemaException from dlt.common.schema.utils import ( get_write_disposition, @@ -344,6 +345,12 @@ def _verify_schema(self) -> None: table_name, self.capabilities.max_identifier_length, ) + if table.get("write_disposition") == "merge": + if "x-merge-strategy" in table and table["x-merge-strategy"] not in MERGE_STRATEGIES: # type: ignore[typeddict-item] + raise SchemaException( + f'"{table["x-merge-strategy"]}" is not a valid merge strategy. ' # type: ignore[typeddict-item] + f"""Allowed values: {', '.join(['"' + s + '"' for s in MERGE_STRATEGIES])}.""" + ) if has_column_with_prop(table, "hard_delete"): if len(get_columns_names_with_prop(table, "hard_delete")) > 1: raise SchemaException( diff --git a/dlt/common/json/__init__.py b/dlt/common/json/__init__.py index 371c74e54a..cf68e5d3d4 100644 --- a/dlt/common/json/__init__.py +++ b/dlt/common/json/__init__.py @@ -12,7 +12,7 @@ except ImportError: PydanticBaseModel = None # type: ignore[misc] -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.arithmetics import Decimal from dlt.common.wei import Wei from dlt.common.utils import map_nested_in_place @@ -99,19 +99,19 @@ def _datetime_decoder(obj: str) -> datetime: # Backwards compatibility for data encoded with previous dlt version # fromisoformat does not support Z suffix (until py3.11) obj = obj[:-1] + "+00:00" - return pendulum.DateTime.fromisoformat(obj) # type: ignore[attr-defined, no-any-return] + return pendulum.DateTime.fromisoformat(obj) # define decoder for each prefix DECODERS: List[Callable[[Any], Any]] = [ Decimal, _datetime_decoder, - pendulum.Date.fromisoformat, # type: ignore[attr-defined] + pendulum.Date.fromisoformat, UUID, HexBytes, base64.b64decode, Wei, - pendulum.Time.fromisoformat, # type: ignore[attr-defined] + pendulum.Time.fromisoformat, ] # how many decoders? PUA_CHARACTER_MAX = len(DECODERS) diff --git a/dlt/common/libs/numpy.py b/dlt/common/libs/numpy.py index ccf255c6a8..0f3d1dc612 100644 --- a/dlt/common/libs/numpy.py +++ b/dlt/common/libs/numpy.py @@ -3,4 +3,4 @@ try: import numpy except ModuleNotFoundError: - raise MissingDependencyException("DLT Numpy Helpers", ["numpy"]) + raise MissingDependencyException("dlt Numpy Helpers", ["numpy"]) diff --git a/dlt/common/libs/pandas.py b/dlt/common/libs/pandas.py index 7a94dcf6e2..022aa9b9cd 100644 --- a/dlt/common/libs/pandas.py +++ b/dlt/common/libs/pandas.py @@ -4,7 +4,7 @@ try: import pandas except ModuleNotFoundError: - raise MissingDependencyException("DLT Pandas Helpers", ["pandas"]) + raise MissingDependencyException("dlt Pandas Helpers", ["pandas"]) def pandas_to_arrow(df: pandas.DataFrame) -> Any: diff --git a/dlt/common/libs/pyarrow.py b/dlt/common/libs/pyarrow.py index 3380157600..58ddf69cea 100644 --- a/dlt/common/libs/pyarrow.py +++ b/dlt/common/libs/pyarrow.py @@ -15,7 +15,7 @@ ) from dlt import version -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.exceptions import MissingDependencyException from dlt.common.schema.typing import DLT_NAME_PREFIX, TTableSchemaColumns @@ -119,7 +119,7 @@ def get_pyarrow_int(precision: Optional[int]) -> Any: return pyarrow.int64() -def _get_column_type_from_py_arrow(dtype: pyarrow.DataType) -> TColumnType: +def get_column_type_from_py_arrow(dtype: pyarrow.DataType) -> TColumnType: """Returns (data_type, precision, scale) tuple from pyarrow.DataType""" if pyarrow.types.is_string(dtype) or pyarrow.types.is_large_string(dtype): return dict(data_type="text") @@ -226,14 +226,14 @@ def should_normalize_arrow_schema( ) -> Tuple[bool, Mapping[str, str], Dict[str, str], TTableSchemaColumns]: rename_mapping = get_normalized_arrow_fields_mapping(schema, naming) rev_mapping = {v: k for k, v in rename_mapping.items()} - dlt_table_prefix = naming.normalize_table_identifier(DLT_NAME_PREFIX) + dlt_tables = list(map(naming.normalize_table_identifier, ("_dlt_id", "_dlt_load_id"))) # remove all columns that are dlt columns but are not present in arrow schema. we do not want to add such columns # that should happen in the normalizer columns = { name: column for name, column in columns.items() - if not name.startswith(dlt_table_prefix) or name in rev_mapping + if name not in dlt_tables or name in rev_mapping } # check if nothing to rename @@ -322,7 +322,7 @@ def py_arrow_to_table_schema_columns(schema: pyarrow.Schema) -> TTableSchemaColu result[field.name] = { "name": field.name, "nullable": field.nullable, - **_get_column_type_from_py_arrow(field.type), + **get_column_type_from_py_arrow(field.type), } return result diff --git a/dlt/common/normalizers/json/relational.py b/dlt/common/normalizers/json/relational.py index e33bf2ab35..da38ac60a7 100644 --- a/dlt/common/normalizers/json/relational.py +++ b/dlt/common/normalizers/json/relational.py @@ -1,13 +1,21 @@ +from functools import lru_cache from typing import Dict, List, Mapping, Optional, Sequence, Tuple, cast, TypedDict, Any -from dlt.common.data_types.typing import TDataType +from dlt.common.json import json from dlt.common.normalizers.exceptions import InvalidJsonNormalizer from dlt.common.normalizers.typing import TJSONNormalizer from dlt.common.normalizers.utils import generate_dlt_id, DLT_ID_LENGTH_BYTES from dlt.common.typing import DictStrAny, DictStrStr, TDataItem, StrAny from dlt.common.schema import Schema -from dlt.common.schema.typing import TColumnSchema, TColumnName, TSimpleRegex -from dlt.common.schema.utils import column_name_validator +from dlt.common.schema.typing import ( + TTableSchema, + TColumnSchema, + TColumnName, + TSimpleRegex, + DLT_NAME_PREFIX, +) +from dlt.common.schema.utils import column_name_validator, get_validity_column_names +from dlt.common.schema.exceptions import ColumnNameConflictException from dlt.common.utils import digest128, update_dict_nested from dlt.common.normalizers.json import ( TNormalizedRowIterator, @@ -127,6 +135,18 @@ def norm_row_dicts(dict_row: StrAny, __r_lvl: int, path: Tuple[str, ...] = ()) - norm_row_dicts(dict_row, _r_lvl) return cast(TDataItemRow, out_rec_row), out_rec_list + @staticmethod + def get_row_hash(row: Dict[str, Any]) -> str: + """Returns hash of row. + + Hash includes column names and values and is ordered by column name. + Excludes dlt system columns. + Can be used as deterministic row identifier. + """ + row_filtered = {k: v for k, v in row.items() if not k.startswith(DLT_NAME_PREFIX)} + row_str = json.dumps(row_filtered, sort_keys=True) + return digest128(row_str, DLT_ID_LENGTH_BYTES) + @staticmethod def _get_child_row_hash(parent_row_id: str, child_table: str, list_idx: int) -> str: # create deterministic unique id of the child row taking into account that all lists are ordered @@ -220,10 +240,14 @@ def _normalize_row( parent_row_id: Optional[str] = None, pos: Optional[int] = None, _r_lvl: int = 0, + row_hash: bool = False, ) -> TNormalizedRowIterator: schema = self.schema table = schema.naming.shorten_fragments(*parent_path, *ident_path) - + # compute row hash and set as row id + if row_hash: + row_id = self.get_row_hash(dict_row) # type: ignore[arg-type] + dict_row["_dlt_id"] = row_id # flatten current row and extract all lists to recur into flattened_row, lists = self._flatten(table, dict_row, _r_lvl) # always extend row @@ -296,10 +320,18 @@ def normalize_data_item( row = cast(TDataItemRowRoot, item) # identify load id if loaded data must be processed after loading incrementally row["_dlt_load_id"] = load_id + # determine if row hash should be used as dlt id + row_hash = False + if self._is_scd2_table(self.schema, table_name): + row_hash = self._dlt_id_is_row_hash(self.schema, table_name) + self._validate_validity_column_names( + self._get_validity_column_names(self.schema, table_name), item + ) yield from self._normalize_row( cast(TDataItemRowChild, row), {}, (self.schema.naming.normalize_table_identifier(table_name),), + row_hash=row_hash, ) @classmethod @@ -333,3 +365,33 @@ def _validate_normalizer_config(schema: Schema, config: RelationalNormalizerConf "./normalizers/json/config", validator_f=column_name_validator(schema.naming), ) + + @staticmethod + @lru_cache(maxsize=None) + def _is_scd2_table(schema: Schema, table_name: str) -> bool: + if table_name in schema.data_table_names(): + if schema.get_table(table_name).get("x-merge-strategy") == "scd2": + return True + return False + + @staticmethod + @lru_cache(maxsize=None) + def _get_validity_column_names(schema: Schema, table_name: str) -> List[Optional[str]]: + return get_validity_column_names(schema.get_table(table_name)) + + @staticmethod + @lru_cache(maxsize=None) + def _dlt_id_is_row_hash(schema: Schema, table_name: str) -> bool: + return schema.get_table(table_name)["columns"].get("_dlt_id", dict()).get("x-row-version", False) # type: ignore[return-value] + + @staticmethod + def _validate_validity_column_names( + validity_column_names: List[Optional[str]], item: TDataItem + ) -> None: + """Raises exception if configured validity column name appears in data item.""" + for validity_column_name in validity_column_names: + if validity_column_name in item.keys(): + raise ColumnNameConflictException( + "Found column in data item with same name as validity column" + f' "{validity_column_name}".' + ) diff --git a/dlt/common/pipeline.py b/dlt/common/pipeline.py index 7c117d4612..8baf872752 100644 --- a/dlt/common/pipeline.py +++ b/dlt/common/pipeline.py @@ -36,7 +36,12 @@ from dlt.common.destination.exceptions import DestinationHasFailedJobs from dlt.common.exceptions import PipelineStateNotAvailable, SourceSectionNotAvailable from dlt.common.schema import Schema -from dlt.common.schema.typing import TColumnNames, TColumnSchema, TWriteDisposition, TSchemaContract +from dlt.common.schema.typing import ( + TColumnNames, + TColumnSchema, + TWriteDispositionConfig, + TSchemaContract, +) from dlt.common.source import get_current_pipe_name from dlt.common.storages.load_storage import LoadPackageInfo from dlt.common.time import ensure_pendulum_datetime, precise_time @@ -521,7 +526,7 @@ def run( dataset_name: str = None, credentials: Any = None, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: Sequence[TColumnSchema] = None, primary_key: TColumnNames = None, schema: Schema = None, @@ -544,7 +549,7 @@ def __call__( dataset_name: str = None, credentials: Any = None, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: Sequence[TColumnSchema] = None, schema: Schema = None, loader_file_format: TLoaderFileFormat = None, diff --git a/dlt/common/runners/pool_runner.py b/dlt/common/runners/pool_runner.py index 491c74cd18..c691347529 100644 --- a/dlt/common/runners/pool_runner.py +++ b/dlt/common/runners/pool_runner.py @@ -4,13 +4,14 @@ from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor, Future from typing_extensions import ParamSpec -from dlt.common import logger, sleep +from dlt.common import logger from dlt.common.configuration.container import Container from dlt.common.runtime import init from dlt.common.runners.runnable import Runnable, TExecutor from dlt.common.runners.configuration import PoolRunnerConfiguration from dlt.common.runners.typing import TRunMetrics from dlt.common.runtime import signals +from dlt.common.runtime.signals import sleep from dlt.common.exceptions import SignalReceivedException diff --git a/dlt/common/runtime/prometheus.py b/dlt/common/runtime/prometheus.py index 07c960efe7..9bc89211be 100644 --- a/dlt/common/runtime/prometheus.py +++ b/dlt/common/runtime/prometheus.py @@ -3,7 +3,6 @@ from prometheus_client.metrics import MetricWrapperBase from dlt.common.configuration.specs import RunConfiguration -from dlt.common import logger from dlt.common.runtime.exec_info import dlt_version_info from dlt.common.typing import DictStrAny, StrAny diff --git a/dlt/common/runtime/segment.py b/dlt/common/runtime/segment.py index 70b81fb4f4..ac64591072 100644 --- a/dlt/common/runtime/segment.py +++ b/dlt/common/runtime/segment.py @@ -6,13 +6,11 @@ import atexit import base64 import requests -from concurrent.futures import ThreadPoolExecutor from typing import Literal, Optional from dlt.common.configuration.paths import get_dlt_data_dir from dlt.common import logger from dlt.common.managed_thread_pool import ManagedThreadPool - from dlt.common.configuration.specs import RunConfiguration from dlt.common.runtime.exec_info import get_execution_context, TExecutionContext from dlt.common.typing import DictStrAny, StrAny diff --git a/dlt/common/runtime/slack.py b/dlt/common/runtime/slack.py index b1e090098d..75c01aac25 100644 --- a/dlt/common/runtime/slack.py +++ b/dlt/common/runtime/slack.py @@ -2,7 +2,8 @@ def send_slack_message(incoming_hook: str, message: str, is_markdown: bool = True) -> None: - from dlt.common import json, logger + from dlt.common import logger + from dlt.common.json import json """Sends a `message` to Slack `incoming_hook`, by default formatted as markdown.""" r = requests.post( diff --git a/dlt/common/schema/detections.py b/dlt/common/schema/detections.py index 30b23706af..c9e0e05be9 100644 --- a/dlt/common/schema/detections.py +++ b/dlt/common/schema/detections.py @@ -3,7 +3,8 @@ from hexbytes import HexBytes -from dlt.common import pendulum, Wei +from dlt.common.pendulum import pendulum +from dlt.common.wei import Wei from dlt.common.data_types import TDataType from dlt.common.time import parse_iso_like_datetime diff --git a/dlt/common/schema/exceptions.py b/dlt/common/schema/exceptions.py index 96341ab8b4..678f4de15e 100644 --- a/dlt/common/schema/exceptions.py +++ b/dlt/common/schema/exceptions.py @@ -152,3 +152,7 @@ class UnknownTableException(SchemaException): def __init__(self, table_name: str) -> None: self.table_name = table_name super().__init__(f"Trying to access unknown table {table_name}.") + + +class ColumnNameConflictException(SchemaException): + pass diff --git a/dlt/common/schema/schema.py b/dlt/common/schema/schema.py index c738f1753e..740e578ef2 100644 --- a/dlt/common/schema/schema.py +++ b/dlt/common/schema/schema.py @@ -1,6 +1,5 @@ from copy import copy, deepcopy from typing import ClassVar, Dict, List, Mapping, Optional, Sequence, Tuple, Any, cast, Literal -from dlt.common import json from dlt.common.schema.migrations import migrate_schema from dlt.common.utils import extend_list_deduplicated diff --git a/dlt/common/schema/typing.py b/dlt/common/schema/typing.py index ec60e4c365..e1022cfa84 100644 --- a/dlt/common/schema/typing.py +++ b/dlt/common/schema/typing.py @@ -7,7 +7,6 @@ Optional, Sequence, Set, - Tuple, Type, TypedDict, NewType, @@ -34,6 +33,8 @@ LOADS_TABLE_NAME = "_dlt_loads" STATE_TABLE_NAME = "_dlt_pipeline_state" DLT_NAME_PREFIX = "_dlt" +DEFAULT_VALIDITY_COLUMN_NAMES = ["_dlt_valid_from", "_dlt_valid_to"] +"""Default values for validity column names used in `scd2` merge strategy.""" TColumnProp = Literal[ "name", @@ -64,7 +65,6 @@ "dedup_sort", ] """Known hints of a column used to declare hint regexes.""" -TWriteDisposition = Literal["skip", "append", "replace", "merge"] TTableFormat = Literal["iceberg", "parquet", "jsonl"] TTypeDetections = Literal[ "timestamp", "iso_timestamp", "iso_date", "large_integer", "hexbytes_to_text", "wei_to_double" @@ -86,7 +86,6 @@ "root_key", ] ) -WRITE_DISPOSITIONS: Set[TWriteDisposition] = set(get_args(TWriteDisposition)) class TColumnType(TypedDict, total=False): @@ -155,6 +154,27 @@ class NormalizerInfo(TypedDict, total=True): new_table: bool +TWriteDisposition = Literal["skip", "append", "replace", "merge"] +TLoaderMergeStrategy = Literal["delete-insert", "scd2"] + + +WRITE_DISPOSITIONS: Set[TWriteDisposition] = set(get_args(TWriteDisposition)) +MERGE_STRATEGIES: Set[TLoaderMergeStrategy] = set(get_args(TLoaderMergeStrategy)) + + +class TWriteDispositionDict(TypedDict): + disposition: TWriteDisposition + + +class TMergeDispositionDict(TWriteDispositionDict, total=False): + strategy: Optional[TLoaderMergeStrategy] + validity_column_names: Optional[List[str]] + row_version_column_name: Optional[str] + + +TWriteDispositionConfig = Union[TWriteDisposition, TWriteDispositionDict, TMergeDispositionDict] + + # TypedDict that defines properties of a table diff --git a/dlt/common/schema/utils.py b/dlt/common/schema/utils.py index 4c1071a8a9..8da9029124 100644 --- a/dlt/common/schema/utils.py +++ b/dlt/common/schema/utils.py @@ -5,7 +5,7 @@ from copy import deepcopy, copy from typing import Dict, List, Sequence, Tuple, Type, Any, cast, Iterable, Optional, Union -from dlt.common import json +from dlt.common.json import json from dlt.common.data_types import TDataType from dlt.common.exceptions import DictValidationException from dlt.common.normalizers.naming import NamingConvention @@ -34,6 +34,7 @@ TTypeDetectionFunc, TTypeDetections, TWriteDisposition, + TLoaderMergeStrategy, TSchemaContract, TSortOrder, ) @@ -47,6 +48,7 @@ RE_NON_ALPHANUMERIC_UNDERSCORE = re.compile(r"[^a-zA-Z\d_]") DEFAULT_WRITE_DISPOSITION: TWriteDisposition = "append" +DEFAULT_MERGE_STRATEGY: TLoaderMergeStrategy = "delete-insert" def is_valid_schema_name(name: str) -> bool: @@ -516,6 +518,13 @@ def get_dedup_sort_tuple( return (dedup_sort_col, dedup_sort_order) +def get_validity_column_names(table: TTableSchema) -> List[Optional[str]]: + return [ + get_first_column_name_with_prop(table, "x-valid-from"), + get_first_column_name_with_prop(table, "x-valid-to"), + ] + + def merge_schema_updates(schema_updates: Sequence[TSchemaUpdate]) -> TSchemaTables: aggregated_update: TSchemaTables = {} for schema_update in schema_updates: diff --git a/dlt/common/storages/__init__.py b/dlt/common/storages/__init__.py index e5feeaba57..7bb3c0cf97 100644 --- a/dlt/common/storages/__init__.py +++ b/dlt/common/storages/__init__.py @@ -9,6 +9,7 @@ LoadPackageInfo, PackageStorage, TJobState, + create_load_id, ) from .data_item_storage import DataItemStorage from .load_storage import LoadStorage @@ -40,6 +41,7 @@ "LoadPackageInfo", "PackageStorage", "TJobState", + "create_load_id", "fsspec_from_config", "fsspec_filesystem", ] diff --git a/dlt/common/storages/fsspec_filesystem.py b/dlt/common/storages/fsspec_filesystem.py index b1cbc11bf9..3a2b483970 100644 --- a/dlt/common/storages/fsspec_filesystem.py +++ b/dlt/common/storages/fsspec_filesystem.py @@ -24,7 +24,7 @@ from fsspec.core import url_to_fs from dlt import version -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.configuration.specs import ( GcpCredentials, AwsCredentials, diff --git a/dlt/common/storages/fsspecs/google_drive.py b/dlt/common/storages/fsspecs/google_drive.py index 3bc4b1d7d7..1be862668c 100644 --- a/dlt/common/storages/fsspecs/google_drive.py +++ b/dlt/common/storages/fsspecs/google_drive.py @@ -1,7 +1,7 @@ import posixpath from typing import Any, Dict, List, Literal, Optional, Tuple -from dlt.common import json +from dlt.common.json import json from dlt.common.configuration.specs import GcpCredentials, GcpOAuthCredentials from dlt.common.exceptions import MissingDependencyException diff --git a/dlt/common/storages/load_package.py b/dlt/common/storages/load_package.py index 3ca5056d8e..1c76fd39cd 100644 --- a/dlt/common/storages/load_package.py +++ b/dlt/common/storages/load_package.py @@ -21,17 +21,16 @@ cast, Any, Tuple, - TYPE_CHECKING, TypedDict, ) +from typing_extensions import NotRequired -from dlt.common import pendulum, json - +from dlt.common.pendulum import pendulum +from dlt.common.json import json from dlt.common.configuration import configspec from dlt.common.configuration.specs import ContainerInjectableContext from dlt.common.configuration.exceptions import ContextDefaultCannotBeCreated from dlt.common.configuration.container import Container - from dlt.common.data_writers import DataWriter, new_file_id from dlt.common.destination import TLoaderFileFormat from dlt.common.exceptions import TerminalValueError @@ -46,16 +45,18 @@ bump_state_version_if_modified, TVersionedState, default_versioned_state, + json_decode_state, + json_encode_state, ) -from typing_extensions import NotRequired +from dlt.common.time import precise_time TJobFileFormat = Literal["sql", "reference", TLoaderFileFormat] """Loader file formats with internal job types""" class TLoadPackageState(TVersionedState, total=False): - created_at: str - """Timestamp when the loadpackage was created""" + created_at: DateTime + """Timestamp when the load package was created""" """A section of state that does not participate in change merging and version control""" destination_state: NotRequired[Dict[str, Any]] @@ -104,6 +105,16 @@ def default_load_package_state() -> TLoadPackageState: } +def create_load_id() -> str: + """Creates new package load id which is the current unix timestamp converted to string. + Load ids must have the following properties: + - They must maintain increase order over time for a particular dlt schema loaded to particular destination and dataset + `dlt` executes packages in order of load ids + `dlt` considers a state with the highest load id to be the most up to date when restoring state from destination + """ + return str(precise_time()) + + # folders to manage load jobs in a single load package TJobState = Literal["new_jobs", "failed_jobs", "started_jobs", "completed_jobs"] WORKING_FOLDERS: Set[TJobState] = set(get_args(TJobState)) @@ -404,18 +415,23 @@ def complete_job(self, load_id: str, file_name: str) -> str: # Create and drop entities # - def create_package(self, load_id: str) -> None: + def create_package(self, load_id: str, initial_state: TLoadPackageState = None) -> None: self.storage.create_folder(load_id) # create processing directories self.storage.create_folder(os.path.join(load_id, PackageStorage.NEW_JOBS_FOLDER)) self.storage.create_folder(os.path.join(load_id, PackageStorage.COMPLETED_JOBS_FOLDER)) self.storage.create_folder(os.path.join(load_id, PackageStorage.FAILED_JOBS_FOLDER)) self.storage.create_folder(os.path.join(load_id, PackageStorage.STARTED_JOBS_FOLDER)) - # ensure created timestamp is set in state when load package is created - state = self.get_load_package_state(load_id) + # use initial state or create a new by loading non existing state + state = self.get_load_package_state(load_id) if initial_state is None else initial_state if not state.get("created_at"): - state["created_at"] = pendulum.now().to_iso8601_string() - self.save_load_package_state(load_id, state) + # try to parse load_id as unix timestamp + try: + created_at = float(load_id) + except Exception: + created_at = precise_time() + state["created_at"] = pendulum.from_timestamp(created_at) + self.save_load_package_state(load_id, state) def complete_loading_package(self, load_id: str, load_state: TLoadPackageStatus) -> str: """Completes loading the package by writing marker file with`package_state. Returns path to the completed package""" @@ -424,6 +440,7 @@ def complete_loading_package(self, load_id: str, load_state: TLoadPackageStatus) self.storage.save( os.path.join(load_path, PackageStorage.PACKAGE_COMPLETED_FILE_NAME), load_state ) + # TODO: also modify state return load_path def remove_completed_jobs(self, load_id: str) -> None: @@ -472,7 +489,7 @@ def get_load_package_state(self, load_id: str) -> TLoadPackageState: raise LoadPackageNotFound(load_id) try: state_dump = self.storage.load(self.get_load_package_state_path(load_id)) - state = json.loads(state_dump) + state = json_decode_state(state_dump) return migrate_load_package_state( state, state["_state_engine_version"], LOAD_PACKAGE_STATE_ENGINE_VERSION ) @@ -486,7 +503,7 @@ def save_load_package_state(self, load_id: str, state: TLoadPackageState) -> Non bump_loadpackage_state_version_if_modified(state) self.storage.save( self.get_load_package_state_path(load_id), - json.dumps(state), + json_encode_state(state), ) def get_load_package_state_path(self, load_id: str) -> str: diff --git a/dlt/common/storages/load_storage.py b/dlt/common/storages/load_storage.py index 8b5109d9e2..97e62201e5 100644 --- a/dlt/common/storages/load_storage.py +++ b/dlt/common/storages/load_storage.py @@ -2,7 +2,7 @@ from typing import Iterable, List, Optional, Sequence from dlt.common.data_writers.exceptions import DataWriterNotFound -from dlt.common import json +from dlt.common.json import json from dlt.common.configuration import known_sections from dlt.common.configuration.inject import with_config from dlt.common.destination import ALL_SUPPORTED_FILE_FORMATS, TLoaderFileFormat @@ -105,6 +105,14 @@ def create_item_storage( pass raise + def import_extracted_package( + self, load_id: str, extract_package_storage: PackageStorage + ) -> None: + # pass the original state + self.new_packages.create_package( + load_id, extract_package_storage.get_load_package_state(load_id) + ) + def list_new_jobs(self, load_id: str) -> Sequence[str]: """Lists all jobs in new jobs folder of normalized package storage and checks if file formats are supported""" new_jobs = self.normalized_packages.list_new_jobs(load_id) diff --git a/dlt/common/storages/schema_storage.py b/dlt/common/storages/schema_storage.py index 23b695b839..1afed18929 100644 --- a/dlt/common/storages/schema_storage.py +++ b/dlt/common/storages/schema_storage.py @@ -1,7 +1,8 @@ import yaml from typing import Iterator, List, Mapping, Tuple, cast -from dlt.common import json, logger +from dlt.common import logger +from dlt.common.json import json from dlt.common.configuration import with_config from dlt.common.configuration.accessors import config from dlt.common.schema.utils import to_pretty_json, to_pretty_yaml diff --git a/dlt/common/time.py b/dlt/common/time.py index d3c8f9746c..161205deb8 100644 --- a/dlt/common/time.py +++ b/dlt/common/time.py @@ -208,10 +208,12 @@ def to_seconds(td: Optional[TimedeltaSeconds]) -> Optional[float]: return td -T = TypeVar("T", bound=Union[pendulum.DateTime, pendulum.Time]) +TTimeWithPrecision = TypeVar("TTimeWithPrecision", bound=Union[pendulum.DateTime, pendulum.Time]) -def reduce_pendulum_datetime_precision(value: T, microsecond_precision: int) -> T: - if microsecond_precision >= 6: +def reduce_pendulum_datetime_precision( + value: TTimeWithPrecision, precision: int +) -> TTimeWithPrecision: + if precision >= 6: return value - return value.replace(microsecond=value.microsecond // 10 ** (6 - microsecond_precision) * 10 ** (6 - microsecond_precision)) # type: ignore + return value.replace(microsecond=value.microsecond // 10 ** (6 - precision) * 10 ** (6 - precision)) # type: ignore diff --git a/dlt/common/utils.py b/dlt/common/utils.py index 4ddde87758..1d3020f4dd 100644 --- a/dlt/common/utils.py +++ b/dlt/common/utils.py @@ -296,7 +296,7 @@ def _is_recursive_merge(a: StrAny, b: StrAny) -> bool: if key in dst: if _is_recursive_merge(dst[key], src[key]): # If the key for both `dst` and `src` are both Mapping types (e.g. dict), then recurse. - update_dict_nested(dst[key], src[key]) + update_dict_nested(dst[key], src[key], keep_dst_values=keep_dst_values) elif dst[key] is src[key]: # If a key exists in both objects and the values are `same`, the value from the `dst` object will be used. pass diff --git a/dlt/common/versioned_state.py b/dlt/common/versioned_state.py index 6f45df83c4..52a26c6943 100644 --- a/dlt/common/versioned_state.py +++ b/dlt/common/versioned_state.py @@ -1,9 +1,12 @@ import base64 import hashlib +import binascii from copy import copy +from typing import TypedDict, List, Tuple, Mapping -from dlt.common import json -from typing import TypedDict, List, Tuple +from dlt.common.json import json +from dlt.common.typing import DictStrAny +from dlt.common.utils import compressed_b64decode, compressed_b64encode class TVersionedState(TypedDict, total=False): @@ -19,7 +22,7 @@ def generate_state_version_hash(state: TVersionedState, exclude_attrs: List[str] exclude_attrs.extend(["_state_version", "_state_engine_version", "_version_hash"]) for attr in exclude_attrs: state_copy.pop(attr, None) # type: ignore - content = json.typed_dumpb(state_copy, sort_keys=True) # type: ignore + content = json.typed_dumpb(state_copy, sort_keys=True) h = hashlib.sha3_256(content) return base64.b64encode(h.digest()).decode("ascii") @@ -42,3 +45,24 @@ def bump_state_version_if_modified( def default_versioned_state() -> TVersionedState: return {"_state_version": 0, "_state_engine_version": 1} + + +def json_encode_state(state: TVersionedState) -> str: + return json.typed_dumps(state) + + +def json_decode_state(state_str: str) -> DictStrAny: + return json.typed_loads(state_str) # type: ignore[no-any-return] + + +def compress_state(state: TVersionedState) -> str: + return compressed_b64encode(json.typed_dumpb(state)) + + +def decompress_state(state_str: str) -> DictStrAny: + try: + state_bytes = compressed_b64decode(state_str) + except binascii.Error: + return json.typed_loads(state_str) # type: ignore[no-any-return] + else: + return json.typed_loadb(state_bytes) # type: ignore[no-any-return] diff --git a/dlt/destinations/decorators.py b/dlt/destinations/decorators.py index a920d336a2..8e0b5d5ee8 100644 --- a/dlt/destinations/decorators.py +++ b/dlt/destinations/decorators.py @@ -7,14 +7,15 @@ from functools import wraps from dlt.common import logger +from dlt.common.destination import TLoaderFileFormat +from dlt.common.typing import TDataItems +from dlt.common.schema import TTableSchema + from dlt.destinations.impl.destination.factory import destination as _destination from dlt.destinations.impl.destination.configuration import ( TDestinationCallableParams, CustomDestinationClientConfiguration, ) -from dlt.common.destination import TLoaderFileFormat -from dlt.common.typing import TDataItems -from dlt.common.schema import TTableSchema def destination( diff --git a/dlt/destinations/impl/bigquery/bigquery.py b/dlt/destinations/impl/bigquery/bigquery.py index 86448bd011..0ac042a056 100644 --- a/dlt/destinations/impl/bigquery/bigquery.py +++ b/dlt/destinations/impl/bigquery/bigquery.py @@ -9,7 +9,8 @@ from google.api_core import retry from google.cloud.bigquery.retry import _RETRYABLE_REASONS -from dlt.common import json, logger +from dlt.common import logger +from dlt.common.json import json from dlt.common.destination import DestinationCapabilitiesContext from dlt.common.destination.reference import ( FollowupJob, diff --git a/dlt/destinations/impl/databricks/sql_client.py b/dlt/destinations/impl/databricks/sql_client.py index 68ea863cc4..7e2487593d 100644 --- a/dlt/destinations/impl/databricks/sql_client.py +++ b/dlt/destinations/impl/databricks/sql_client.py @@ -8,8 +8,6 @@ ) from databricks.sql.exc import Error as DatabricksSqlError -from dlt.common import pendulum -from dlt.common import logger from dlt.common.destination import DestinationCapabilitiesContext from dlt.destinations.exceptions import ( DatabaseTerminalException, diff --git a/dlt/destinations/impl/destination/factory.py b/dlt/destinations/impl/destination/factory.py index 8395c66ac8..3ae6f2e876 100644 --- a/dlt/destinations/impl/destination/factory.py +++ b/dlt/destinations/impl/destination/factory.py @@ -1,14 +1,13 @@ import typing as t import inspect from importlib import import_module - from types import ModuleType -from dlt.common.typing import AnyFun +from dlt.common import logger +from dlt.common.typing import AnyFun from dlt.common.destination import Destination, DestinationCapabilitiesContext, TLoaderFileFormat from dlt.common.configuration import known_sections, with_config, get_fun_spec from dlt.common.configuration.exceptions import ConfigurationValueError -from dlt.common import logger from dlt.common.utils import get_callable_name, is_inner_callable from dlt.destinations.exceptions import DestinationTransientException diff --git a/dlt/destinations/impl/dummy/dummy.py b/dlt/destinations/impl/dummy/dummy.py index 0d91220d88..bafac210cc 100644 --- a/dlt/destinations/impl/dummy/dummy.py +++ b/dlt/destinations/impl/dummy/dummy.py @@ -14,7 +14,7 @@ List, ) -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.schema import Schema, TTableSchema, TSchemaTables from dlt.common.storages import FileStorage from dlt.common.destination import DestinationCapabilitiesContext diff --git a/dlt/destinations/impl/qdrant/qdrant_client.py b/dlt/destinations/impl/qdrant/qdrant_client.py index febfe38ec9..5a5e5f8cfd 100644 --- a/dlt/destinations/impl/qdrant/qdrant_client.py +++ b/dlt/destinations/impl/qdrant/qdrant_client.py @@ -1,7 +1,9 @@ from types import TracebackType from typing import ClassVar, Optional, Sequence, List, Dict, Type, Iterable, Any, IO -from dlt.common import json, pendulum, logger +from dlt.common import logger +from dlt.common.json import json +from dlt.common.pendulum import pendulum from dlt.common.schema import Schema, TTableSchema, TSchemaTables from dlt.common.schema.utils import get_columns_names_with_prop from dlt.common.destination import DestinationCapabilitiesContext diff --git a/dlt/destinations/impl/weaviate/weaviate_client.py b/dlt/destinations/impl/weaviate/weaviate_client.py index 6486a75e6e..ab2bea54ef 100644 --- a/dlt/destinations/impl/weaviate/weaviate_client.py +++ b/dlt/destinations/impl/weaviate/weaviate_client.py @@ -24,7 +24,9 @@ from weaviate.gql.get import GetBuilder from weaviate.util import generate_uuid5 -from dlt.common import json, pendulum, logger +from dlt.common import logger +from dlt.common.json import json +from dlt.common.pendulum import pendulum from dlt.common.typing import StrAny, TFun from dlt.common.time import ensure_pendulum_datetime from dlt.common.schema import Schema, TTableSchema, TSchemaTables, TTableSchemaColumns @@ -491,7 +493,8 @@ def get_stored_state(self, pipeline_name: str) -> Optional[StateInfo]: while True: state_records = self.get_records( self.schema.state_table_name, - sort={"path": ["created_at"], "order": "desc"}, + # search by package load id which is guaranteed to increase over time + sort={"path": ["_dlt_load_id"], "order": "desc"}, where={ "path": ["pipeline_name"], "operator": "Equal", diff --git a/dlt/destinations/job_client_impl.py b/dlt/destinations/job_client_impl.py index ea0d10d11d..7f1403eb30 100644 --- a/dlt/destinations/job_client_impl.py +++ b/dlt/destinations/job_client_impl.py @@ -23,7 +23,9 @@ import zlib import re -from dlt.common import json, pendulum, logger +from dlt.common import logger +from dlt.common.json import json +from dlt.common.pendulum import pendulum from dlt.common.data_types import TDataType from dlt.common.schema.typing import ( COLUMN_HINTS, @@ -363,7 +365,7 @@ def get_stored_state(self, pipeline_name: str) -> StateInfo: query = ( f"SELECT {self.state_table_columns} FROM {state_table} AS s JOIN {loads_table} AS l ON" " l.load_id = s._dlt_load_id WHERE pipeline_name = %s AND l.status = 0 ORDER BY" - " created_at DESC" + " l.load_id DESC" ) with self.sql_client.execute_query(query, pipeline_name) as cur: row = cur.fetchone() diff --git a/dlt/destinations/job_impl.py b/dlt/destinations/job_impl.py index 8e017fc791..218f73cc59 100644 --- a/dlt/destinations/job_impl.py +++ b/dlt/destinations/job_impl.py @@ -3,7 +3,7 @@ import tempfile # noqa: 251 from typing import Dict, Iterable, List -from dlt.common import json +from dlt.common.json import json from dlt.common.destination.reference import NewLoadJob, FollowupJob, TLoadJobState, LoadJob from dlt.common.schema import Schema, TTableSchema from dlt.common.storages import FileStorage diff --git a/dlt/destinations/path_utils.py b/dlt/destinations/path_utils.py index 047cb274e0..5b2ba9d183 100644 --- a/dlt/destinations/path_utils.py +++ b/dlt/destinations/path_utils.py @@ -1,9 +1,10 @@ # this can probably go some other place, but it is shared by destinations, so for now it is here from typing import List, Sequence, Tuple -import pendulum import re +from dlt.common.pendulum import pendulum + from dlt.destinations.exceptions import InvalidFilesystemLayout, CantExtractTablePrefix # TODO: ensure layout only has supported placeholders diff --git a/dlt/destinations/sql_jobs.py b/dlt/destinations/sql_jobs.py index 9c5a080278..eadedb742e 100644 --- a/dlt/destinations/sql_jobs.py +++ b/dlt/destinations/sql_jobs.py @@ -1,13 +1,20 @@ from typing import Any, Dict, List, Sequence, Tuple, cast, TypedDict, Optional import yaml +from dlt.common.data_writers.escape import format_datetime_literal from dlt.common.logger import pretty_format_exception -from dlt.common.schema.typing import TTableSchema, TSortOrder +from dlt.common.pendulum import pendulum +from dlt.common.schema.typing import ( + TTableSchema, + TSortOrder, +) from dlt.common.schema.utils import ( get_columns_names_with_prop, get_first_column_name_with_prop, get_dedup_sort_tuple, + get_validity_column_names, + DEFAULT_MERGE_STRATEGY, ) from dlt.common.storages.load_storage import ParsedLoadJobFileName from dlt.common.utils import uniq_id @@ -15,6 +22,11 @@ from dlt.destinations.exceptions import MergeDispositionException from dlt.destinations.job_impl import NewLoadJobImpl from dlt.destinations.sql_client import SqlClientBase +from dlt.pipeline.current import load_package as current_load_package + + +HIGH_TS = pendulum.datetime(9999, 12, 31) +"""High timestamp used to indicate active records in `scd2` merge strategy.""" class SqlJobParams(TypedDict, total=False): @@ -139,25 +151,17 @@ class SqlMergeJob(SqlBaseJob): failed_text: str = "Tried to generate a merge sql job for the following tables:" @classmethod - def generate_sql( + def generate_sql( # type: ignore[return] cls, table_chain: Sequence[TTableSchema], sql_client: SqlClientBase[Any], params: Optional[SqlJobParams] = None, ) -> List[str]: - """Generates a list of sql statements that merge the data in staging dataset with the data in destination dataset. - - The `table_chain` contains a list schemas of a tables with parent-child relationship, ordered by the ancestry (the root of the tree is first on the list). - The root table is merged using primary_key and merge_key hints which can be compound and be both specified. In that case the OR clause is generated. - The child tables are merged based on propagated `root_key` which is a type of foreign key but always leading to a root table. - - First we store the root_keys of root table elements to be deleted in the temp table. Then we use the temp table to delete records from root and all child tables in the destination dataset. - At the end we copy the data from the staging dataset into destination dataset. - - If a hard_delete column is specified, records flagged as deleted will be excluded from the copy into the destination dataset. - If a dedup_sort column is specified in conjunction with a primary key, records will be sorted before deduplication, so the "latest" record remains. - """ - return cls.gen_merge_sql(table_chain, sql_client) + merge_strategy = table_chain[0].get("x-merge-strategy", DEFAULT_MERGE_STRATEGY) + if merge_strategy == "delete-insert": + return cls.gen_merge_sql(table_chain, sql_client) + elif merge_strategy == "scd2": + return cls.gen_scd2_sql(table_chain, sql_client) @classmethod def _gen_key_table_clauses( @@ -339,6 +343,18 @@ def _to_temp_table(cls, select_sql: str, temp_table_name: str) -> str: def gen_merge_sql( cls, table_chain: Sequence[TTableSchema], sql_client: SqlClientBase[Any] ) -> List[str]: + """Generates a list of sql statements that merge the data in staging dataset with the data in destination dataset. + + The `table_chain` contains a list schemas of a tables with parent-child relationship, ordered by the ancestry (the root of the tree is first on the list). + The root table is merged using primary_key and merge_key hints which can be compound and be both specified. In that case the OR clause is generated. + The child tables are merged based on propagated `root_key` which is a type of foreign key but always leading to a root table. + + First we store the root_keys of root table elements to be deleted in the temp table. Then we use the temp table to delete records from root and all child tables in the destination dataset. + At the end we copy the data from the staging dataset into destination dataset. + + If a hard_delete column is specified, records flagged as deleted will be excluded from the copy into the destination dataset. + If a dedup_sort column is specified in conjunction with a primary key, records will be sorted before deduplication, so the "latest" record remains. + """ sql: List[str] = [] root_table = table_chain[0] @@ -486,3 +502,87 @@ def gen_merge_sql( sql.append(f"INSERT INTO {table_name}({col_str}) {select_sql};") return sql + + @classmethod + def gen_scd2_sql( + cls, table_chain: Sequence[TTableSchema], sql_client: SqlClientBase[Any] + ) -> List[str]: + """Generates SQL statements for the `scd2` merge strategy. + + The root table can be inserted into and updated. + Updates only take place when a record retires (because there is a new version + or it is deleted) and only affect the "valid to" column. + Child tables are insert-only. + """ + sql: List[str] = [] + root_table = table_chain[0] + root_table_name = sql_client.make_qualified_table_name(root_table["name"]) + with sql_client.with_staging_dataset(staging=True): + staging_root_table_name = sql_client.make_qualified_table_name(root_table["name"]) + + # get column names + escape_id = sql_client.capabilities.escape_identifier + from_, to = list(map(escape_id, get_validity_column_names(root_table))) # validity columns + hash_ = escape_id( + get_first_column_name_with_prop(root_table, "x-row-version") + ) # row hash column + + # define values for validity columns + boundary_ts = format_datetime_literal( + current_load_package()["state"]["created_at"], + sql_client.capabilities.timestamp_precision, + ) + active_record_ts = format_datetime_literal( + HIGH_TS, sql_client.capabilities.timestamp_precision + ) + + # retire updated and deleted records + sql.append(f""" + UPDATE {root_table_name} SET {to} = '{boundary_ts}' + WHERE NOT EXISTS ( + SELECT s.{hash_} FROM {staging_root_table_name} AS s + WHERE {root_table_name}.{hash_} = s.{hash_} + ) AND {to} = '{active_record_ts}'; + """) + + # insert new active records in root table + columns = map(escape_id, list(root_table["columns"].keys())) + col_str = ", ".join([c for c in columns if c not in (from_, to)]) + sql.append(f""" + INSERT INTO {root_table_name} ({col_str}, {from_}, {to}) + SELECT {col_str}, '{boundary_ts}' AS {from_}, '{active_record_ts}' AS {to} + FROM {staging_root_table_name} AS s + WHERE NOT EXISTS (SELECT s.{hash_} FROM {root_table_name} AS f WHERE f.{hash_} = s.{hash_}); + """) + + # insert list elements for new active records in child tables + child_tables = table_chain[1:] + if child_tables: + unique_column: str = None + # use unique hint to create temp table with all identifiers to delete + unique_columns = get_columns_names_with_prop(root_table, "unique") + if not unique_columns: + raise MergeDispositionException( + sql_client.fully_qualified_dataset_name(), + staging_root_table_name, + [t["name"] for t in table_chain], + f"There is no unique column (ie _dlt_id) in top table {root_table['name']} so" + " it is not possible to link child tables to it.", + ) + # get first unique column + unique_column = escape_id(unique_columns[0]) + # TODO: - based on deterministic child hashes (OK) + # - if row hash changes all is right + # - if it does not we only capture new records, while we should replace existing with those in stage + # - this write disposition is way more similar to regular merge (how root tables are handled is different, other tables handled same) + for table in child_tables: + table_name = sql_client.make_qualified_table_name(table["name"]) + with sql_client.with_staging_dataset(staging=True): + staging_table_name = sql_client.make_qualified_table_name(table["name"]) + sql.append(f""" + INSERT INTO {table_name} + SELECT * + FROM {staging_table_name} AS s + WHERE NOT EXISTS (SELECT 1 FROM {table_name} AS f WHERE f.{unique_column} = s.{unique_column}); + """) + return sql diff --git a/dlt/extract/decorators.py b/dlt/extract/decorators.py index 28a2aca633..bc85cb4a03 100644 --- a/dlt/extract/decorators.py +++ b/dlt/extract/decorators.py @@ -33,8 +33,8 @@ from dlt.common.schema.schema import Schema from dlt.common.schema.typing import ( TColumnNames, - TTableSchemaColumns, TWriteDisposition, + TWriteDispositionConfig, TAnySchemaColumns, TSchemaContract, TTableFormat, @@ -286,7 +286,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -304,7 +304,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -322,7 +322,7 @@ def resource( /, name: TTableHintTemplate[str] = None, table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -341,7 +341,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -358,7 +358,7 @@ def resource( /, name: TTableHintTemplate[str] = None, table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -400,7 +400,9 @@ def resource( table_name (TTableHintTemplate[str], optional): An table name, if different from `name`. This argument also accepts a callable that is used to dynamically create tables for stream-like resources yielding many datatypes. - write_disposition (Literal["skip", "append", "replace", "merge"], optional): Controls how to write data to a table. `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + write_disposition (TTableHintTemplate[TWriteDispositionConfig], optional): Controls how to write data to a table. Accepts a shorthand string literal or configuration dictionary. + Allowed shorthand string literals: `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + Write behaviour can be further customized through a configuration dictionary. For example, to obtain an SCD2 table provide `write_disposition={"disposition": "merge", "strategy": "scd2"}`. This argument also accepts a callable that is used to dynamically create tables for stream-like resources yielding many datatypes. columns (Sequence[TAnySchemaColumns], optional): A list, dict or pydantic model of column schemas. diff --git a/dlt/extract/extract.py b/dlt/extract/extract.py index 02dd06eaf3..cc2b03c50b 100644 --- a/dlt/extract/extract.py +++ b/dlt/extract/extract.py @@ -24,7 +24,7 @@ TAnySchemaColumns, TColumnNames, TSchemaContract, - TWriteDisposition, + TWriteDispositionConfig, ) from dlt.common.storages import NormalizeStorageConfiguration, LoadPackageInfo, SchemaStorage from dlt.common.storages.load_package import ParsedLoadJobFileName @@ -47,7 +47,7 @@ def data_to_sources( schema: Schema = None, table_name: str = None, parent_table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: TAnySchemaColumns = None, primary_key: TColumnNames = None, schema_contract: TSchemaContract = None, diff --git a/dlt/extract/extractors.py b/dlt/extract/extractors.py index 421250951e..b4afc5b1f8 100644 --- a/dlt/extract/extractors.py +++ b/dlt/extract/extractors.py @@ -213,7 +213,14 @@ class ObjectExtractor(Extractor): class ArrowExtractor(Extractor): - """Extracts arrow data items into parquet""" + """Extracts arrow data items into parquet. Normalizes arrow items column names. + Compares the arrow schema to actual dlt table schema to reorder the columns and to + insert missing columns (without data). + + We do things that normalizer should do here so we do not need to load and save parquet + files again later. + + """ def write_items(self, resource: DltResource, items: TDataItems, meta: Any) -> None: static_table_name = self._get_static_table_name(resource, meta) @@ -284,7 +291,7 @@ def _write_item( items: TDataItems, columns: TTableSchemaColumns = None, ) -> None: - columns = columns or self.schema.tables[table_name]["columns"] + columns = columns or self.schema.get_table_columns(table_name) # Note: `items` is always a list here due to the conversion in `write_table` items = [ pyarrow.normalize_py_arrow_item(item, columns, self.naming, self._caps) @@ -312,23 +319,28 @@ def _compute_table( # normalize arrow table before merging arrow_table = self.schema.normalize_table_identifiers(arrow_table) # issue warnings when overriding computed with arrow + override_warn: bool = False for col_name, column in arrow_table["columns"].items(): if src_column := computed_table["columns"].get(col_name): for hint_name, hint in column.items(): if (src_hint := src_column.get(hint_name)) is not None: if src_hint != hint: - logger.warning( + override_warn = True + logger.info( f"In resource: {resource.name}, when merging arrow schema on" f" column {col_name}. The hint {hint_name} value" - f" {src_hint} defined in resource is overwritten from arrow" + f" {src_hint} defined in resource will overwrite arrow hint" f" with value {hint}." ) + if override_warn: + logger.warning( + f"In resource: {resource.name}, when merging arrow schema with dlt schema," + " several column hints were different. dlt schema hints were kept and arrow" + " schema and data were unmodified. It is up to destination to coerce the" + " differences when loading. Change log level to INFO for more details." + ) - # we must override the columns to preserve the order in arrow table - arrow_table["columns"] = update_dict_nested( - arrow_table["columns"], computed_table["columns"], keep_dst_values=True - ) - + update_dict_nested(arrow_table["columns"], computed_table["columns"]) return arrow_table def _compute_and_update_table( diff --git a/dlt/extract/hints.py b/dlt/extract/hints.py index 01a99a23fe..97da7dab9c 100644 --- a/dlt/extract/hints.py +++ b/dlt/extract/hints.py @@ -1,20 +1,28 @@ from copy import copy, deepcopy from typing import TypedDict, cast, Any, Optional, Dict +from dlt.common import logger from dlt.common.schema.typing import ( TColumnNames, TColumnProp, TPartialTableSchema, TTableSchema, TTableSchemaColumns, - TWriteDisposition, + TWriteDispositionConfig, + TMergeDispositionDict, TAnySchemaColumns, TTableFormat, TSchemaContract, + DEFAULT_VALIDITY_COLUMN_NAMES, ) -from dlt.common import logger -from dlt.common.schema.utils import DEFAULT_WRITE_DISPOSITION, merge_column, new_column, new_table -from dlt.common.typing import TDataItem, DictStrAny, DictStrStr +from dlt.common.schema.utils import ( + DEFAULT_WRITE_DISPOSITION, + DEFAULT_MERGE_STRATEGY, + merge_column, + new_column, + new_table, +) +from dlt.common.typing import TDataItem from dlt.common.utils import update_dict_nested from dlt.common.validation import validate_dict_ignoring_xkeys from dlt.extract.exceptions import ( @@ -30,7 +38,7 @@ class TResourceHints(TypedDict, total=False): name: TTableHintTemplate[str] # description: TTableHintTemplate[str] - write_disposition: TTableHintTemplate[TWriteDisposition] + write_disposition: TTableHintTemplate[TWriteDispositionConfig] # table_sealed: Optional[bool] parent: TTableHintTemplate[str] columns: TTableHintTemplate[TTableSchemaColumns] @@ -57,7 +65,7 @@ def __init__(self, hints: TResourceHints, create_table_variant: bool) -> None: def make_hints( table_name: TTableHintTemplate[str] = None, parent_table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -131,13 +139,13 @@ def table_name(self, value: TTableHintTemplate[str]) -> None: self.apply_hints(table_name=value) @property - def write_disposition(self) -> TTableHintTemplate[TWriteDisposition]: + def write_disposition(self) -> TTableHintTemplate[TWriteDispositionConfig]: if self._hints is None or self._hints.get("write_disposition") is None: return DEFAULT_WRITE_DISPOSITION return self._hints.get("write_disposition") @write_disposition.setter - def write_disposition(self, value: TTableHintTemplate[TWriteDisposition]) -> None: + def write_disposition(self, value: TTableHintTemplate[TWriteDispositionConfig]) -> None: self.apply_hints(write_disposition=value) @property @@ -176,8 +184,7 @@ def compute_table_schema(self, item: TDataItem = None, meta: Any = None) -> TTab for k, v in table_template.items() if k not in NATURAL_CALLABLES } # type: ignore - table_schema = self._merge_keys(resolved_template) - table_schema["resource"] = self.name + table_schema = self._create_table_schema(resolved_template, self.name) validate_dict_ignoring_xkeys( spec=TTableSchema, doc=table_schema, @@ -189,7 +196,7 @@ def apply_hints( self, table_name: TTableHintTemplate[str] = None, parent_table_name: TTableHintTemplate[str] = None, - write_disposition: TTableHintTemplate[TWriteDisposition] = None, + write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, merge_key: TTableHintTemplate[TColumnNames] = None, @@ -391,17 +398,76 @@ def _merge_key(hint: TColumnProp, keys: TColumnNames, partial: TPartialTableSche partial["columns"][key][hint] = True @staticmethod - def _merge_keys(t_: TResourceHints) -> TPartialTableSchema: - """Merges resolved keys into columns""" - partial = cast(TPartialTableSchema, t_) - # assert not callable(t_["merge_key"]) - # assert not callable(t_["primary_key"]) - if "primary_key" in t_: - DltResourceHints._merge_key("primary_key", t_.pop("primary_key"), partial) # type: ignore - if "merge_key" in t_: - DltResourceHints._merge_key("merge_key", t_.pop("merge_key"), partial) # type: ignore - - return partial + def _merge_keys(dict_: Dict[str, Any]) -> None: + """Merges primary and merge keys into columns in place.""" + + if "primary_key" in dict_: + DltResourceHints._merge_key("primary_key", dict_.pop("primary_key"), dict_) # type: ignore + if "merge_key" in dict_: + DltResourceHints._merge_key("merge_key", dict_.pop("merge_key"), dict_) # type: ignore + + @staticmethod + def _merge_write_disposition_dict(dict_: Dict[str, Any]) -> None: + """Merges write disposition dictionary into write disposition shorthand and x-hints in place.""" + + if dict_["write_disposition"]["disposition"] == "merge": + DltResourceHints._merge_merge_disposition_dict(dict_) + # reduce merge disposition from dict to shorthand + dict_["write_disposition"] = dict_["write_disposition"]["disposition"] + + @staticmethod + def _merge_merge_disposition_dict(dict_: Dict[str, Any]) -> None: + """Merges merge disposition dict into x-hints on in place.""" + + mddict: TMergeDispositionDict = deepcopy(dict_["write_disposition"]) + if mddict is not None: + dict_["x-merge-strategy"] = ( + mddict["strategy"] if "strategy" in mddict else DEFAULT_MERGE_STRATEGY + ) + # add columns for `scd2` merge strategy + if dict_.get("x-merge-strategy") == "scd2": + if mddict.get("validity_column_names") is None: + from_, to = DEFAULT_VALIDITY_COLUMN_NAMES + else: + from_, to = mddict["validity_column_names"] + dict_["columns"][from_] = { + "name": from_, + "data_type": "timestamp", + "nullable": ( + True + ), # validity columns are empty when first loaded into staging table + "x-valid-from": True, + } + dict_["columns"][to] = { + "name": to, + "data_type": "timestamp", + "nullable": True, + "x-valid-to": True, + } + if mddict.get("row_version_column_name") is None: + hash_ = "_dlt_id" + else: + hash_ = mddict["row_version_column_name"] + dict_["columns"][hash_] = { + "name": hash_, + "nullable": False, + "x-row-version": True, + } + + @staticmethod + def _create_table_schema(resource_hints: TResourceHints, resource_name: str) -> TTableSchema: + """Creates table schema from resource hints and resource name.""" + + dict_ = cast(Dict[str, Any], resource_hints) + DltResourceHints._merge_keys(dict_) + dict_["resource"] = resource_name + if "write_disposition" in dict_: + if isinstance(dict_["write_disposition"], str): + dict_["write_disposition"] = { + "disposition": dict_["write_disposition"] + } # wrap in dict + DltResourceHints._merge_write_disposition_dict(dict_) + return cast(TTableSchema, dict_) @staticmethod def validate_dynamic_hints(template: TResourceHints) -> None: diff --git a/dlt/extract/incremental/__init__.py b/dlt/extract/incremental/__init__.py index e74e87d094..ef7523b207 100644 --- a/dlt/extract/incremental/__init__.py +++ b/dlt/extract/incremental/__init__.py @@ -8,8 +8,9 @@ import dlt +from dlt.common import logger from dlt.common.exceptions import MissingDependencyException -from dlt.common import pendulum, logger +from dlt.common.pendulum import pendulum from dlt.common.jsonpath import compile_path from dlt.common.typing import ( TDataItem, diff --git a/dlt/extract/incremental/transform.py b/dlt/extract/incremental/transform.py index 29b20de7b8..d117b4f1d8 100644 --- a/dlt/extract/incremental/transform.py +++ b/dlt/extract/incremental/transform.py @@ -4,7 +4,7 @@ from dlt.common.exceptions import MissingDependencyException from dlt.common.utils import digest128 from dlt.common.json import json -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.common.typing import TDataItem from dlt.common.jsonpath import find_values, JSONPathFields, compile_path from dlt.extract.incremental.exceptions import ( diff --git a/dlt/extract/storage.py b/dlt/extract/storage.py index 3e01a020ba..de777ad60e 100644 --- a/dlt/extract/storage.py +++ b/dlt/extract/storage.py @@ -3,7 +3,6 @@ from dlt.common.data_writers import TDataItemFormat, DataWriterMetrics, DataWriter, FileWriterSpec from dlt.common.schema import Schema -from dlt.common.schema.typing import TTableSchemaColumns from dlt.common.storages import ( NormalizeStorageConfiguration, NormalizeStorage, @@ -11,10 +10,9 @@ FileStorage, PackageStorage, LoadPackageInfo, + create_load_id, ) from dlt.common.storages.exceptions import LoadPackageNotFound -from dlt.common.typing import TDataItems -from dlt.common.time import precise_time from dlt.common.utils import uniq_id @@ -68,7 +66,7 @@ def create_load_package(self, schema: Schema, reuse_exiting_package: bool = True break load_id = None if not load_id: - load_id = str(precise_time()) + load_id = create_load_id() self.new_packages.create_package(load_id) # always save schema self.new_packages.save_schema(load_id, schema) diff --git a/dlt/helpers/airflow_helper.py b/dlt/helpers/airflow_helper.py index 6677475499..89fe06349b 100644 --- a/dlt/helpers/airflow_helper.py +++ b/dlt/helpers/airflow_helper.py @@ -24,11 +24,13 @@ import dlt -from dlt.common import pendulum from dlt.common import logger +from dlt.common.pendulum import pendulum from dlt.common.runtime.telemetry import with_telemetry + from dlt.common.destination import TLoaderFileFormat -from dlt.common.schema.typing import TWriteDisposition, TSchemaContract +from dlt.common.schema.typing import TWriteDispositionConfig, TSchemaContract + from dlt.common.utils import uniq_id from dlt.common.normalizers.naming.snake_case import NamingConvention as SnakeCaseNamingConvention from dlt.common.configuration.container import Container @@ -165,7 +167,7 @@ def run( pipeline: Pipeline, data: Any, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, loader_file_format: TLoaderFileFormat = None, schema_contract: TSchemaContract = None, pipeline_name: str = None, @@ -180,7 +182,7 @@ def run( data (Any): The data to run the pipeline with table_name (str, optional): The name of the table to which the data should be loaded within the `dataset`. - write_disposition (TWriteDisposition, optional): Same as + write_disposition (TWriteDispositionConfig, optional): Same as in `run` command. loader_file_format (TLoaderFileFormat, optional): The file format the loader will use to create the @@ -210,7 +212,7 @@ def _run( pipeline: Pipeline, data: Any, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, loader_file_format: TLoaderFileFormat = None, schema_contract: TSchemaContract = None, pipeline_name: str = None, @@ -223,7 +225,7 @@ def _run( table_name (str, optional): The name of the table to which the data should be loaded within the `dataset`. - write_disposition (TWriteDisposition, optional): + write_disposition (TWriteDispositionConfig, optional): Same as in `run` command. loader_file_format (TLoaderFileFormat, optional): The file format the loader will use to create @@ -320,7 +322,7 @@ def add_run( *, decompose: Literal["none", "serialize", "parallel", "parallel-isolated"] = "none", table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, loader_file_format: TLoaderFileFormat = None, schema_contract: TSchemaContract = None, **kwargs: Any, @@ -358,7 +360,7 @@ def add_run( Parallel tasks are executed in different pipelines, all derived from the original one, but with the state isolated from each other. table_name: (str): The name of the table to which the data should be loaded within the `dataset` - write_disposition (TWriteDisposition, optional): Same as in `run` command. Defaults to None. + write_disposition (TWriteDispositionConfig, optional): Same as in `run` command. Defaults to None. loader_file_format (Literal["jsonl", "insert_values", "parquet"], optional): The file format the loader will use to create the load package. Not all file_formats are compatible with all destinations. Defaults to the preferred file format of the selected destination. schema_contract (TSchemaContract, optional): On override for the schema contract settings, diff --git a/dlt/helpers/dbt/dbt_utils.py b/dlt/helpers/dbt/dbt_utils.py index b4097e4434..bf14504eaa 100644 --- a/dlt/helpers/dbt/dbt_utils.py +++ b/dlt/helpers/dbt/dbt_utils.py @@ -3,7 +3,8 @@ from typing import Any, Sequence, Optional, Union import warnings -from dlt.common import json, logger +from dlt.common import logger +from dlt.common.json import json from dlt.common.exceptions import MissingDependencyException from dlt.common.typing import StrAny diff --git a/dlt/helpers/streamlit_app/__init__.py b/dlt/helpers/streamlit_app/__init__.py index b304195a5a..bfeb099ef2 100644 --- a/dlt/helpers/streamlit_app/__init__.py +++ b/dlt/helpers/streamlit_app/__init__.py @@ -5,7 +5,7 @@ import streamlit except ModuleNotFoundError: raise MissingDependencyException( - "DLT Streamlit Helpers", + "dlt Streamlit Helpers", ["streamlit"], - "DLT Helpers for Streamlit should be run within a streamlit app.", + "dlt Helpers for Streamlit should be run within a streamlit app.", ) diff --git a/dlt/helpers/streamlit_app/blocks/load_info.py b/dlt/helpers/streamlit_app/blocks/load_info.py index 134b5ad5a4..9482cb5afa 100644 --- a/dlt/helpers/streamlit_app/blocks/load_info.py +++ b/dlt/helpers/streamlit_app/blocks/load_info.py @@ -2,7 +2,7 @@ import humanize import streamlit as st -from dlt.common import pendulum +from dlt.common.pendulum import pendulum from dlt.helpers.streamlit_app.utils import query_data_live from dlt.helpers.streamlit_app.widgets import stat diff --git a/dlt/helpers/streamlit_app/blocks/query.py b/dlt/helpers/streamlit_app/blocks/query.py index a03e9a0cd9..e0cb0100a4 100644 --- a/dlt/helpers/streamlit_app/blocks/query.py +++ b/dlt/helpers/streamlit_app/blocks/query.py @@ -35,9 +35,9 @@ def maybe_run_query( import altair as alt except ModuleNotFoundError: raise MissingDependencyException( - "DLT Streamlit Helpers", + "dlt Streamlit Helpers", ["altair"], - "DLT Helpers for Streamlit should be run within a streamlit" + "dlt Helpers for Streamlit should be run within a streamlit" " app.", ) diff --git a/dlt/helpers/streamlit_app/blocks/resource_state.py b/dlt/helpers/streamlit_app/blocks/resource_state.py index 86b8effc98..dabbea4d46 100644 --- a/dlt/helpers/streamlit_app/blocks/resource_state.py +++ b/dlt/helpers/streamlit_app/blocks/resource_state.py @@ -1,10 +1,10 @@ from typing import Union - -import dlt -import pendulum import streamlit as st import yaml +import dlt +from dlt.common.pendulum import pendulum + def date_to_iso( dumper: yaml.SafeDumper, data: Union[pendulum.Date, pendulum.DateTime] diff --git a/dlt/load/load.py b/dlt/load/load.py index b1f786274e..c5790d467b 100644 --- a/dlt/load/load.py +++ b/dlt/load/load.py @@ -5,7 +5,8 @@ from concurrent.futures import Executor import os -from dlt.common import sleep, logger +from dlt.common import logger +from dlt.common.runtime.signals import sleep from dlt.common.configuration import with_config, known_sections from dlt.common.configuration.resolve import inject_section from dlt.common.configuration.accessors import config diff --git a/dlt/normalize/items_normalizers.py b/dlt/normalize/items_normalizers.py index 1e4e55effd..742125850d 100644 --- a/dlt/normalize/items_normalizers.py +++ b/dlt/normalize/items_normalizers.py @@ -1,7 +1,8 @@ from typing import List, Dict, Set, Any from abc import abstractmethod -from dlt.common import json, logger +from dlt.common import logger +from dlt.common.json import json from dlt.common.data_writers import DataWriterMetrics from dlt.common.data_writers.writers import ArrowToObjectAdapter from dlt.common.json import custom_pua_decode, may_have_pua @@ -326,8 +327,12 @@ def _write_with_dlt_columns( return [schema_update] - def _fix_schema_precisions(self, root_table_name: str) -> List[TSchemaUpdate]: - """Reduce precision of timestamp columns if needed, according to destination caps""" + def _fix_schema_precisions( + self, root_table_name: str, arrow_schema: Any + ) -> List[TSchemaUpdate]: + """Update precision of timestamp columns to the precision of parquet being normalized. + Reduce the precision if it is out of range of destination timestamp precision. + """ schema = self.schema table = schema.tables[root_table_name] max_precision = self.config.destination_capabilities.timestamp_precision @@ -335,9 +340,15 @@ def _fix_schema_precisions(self, root_table_name: str) -> List[TSchemaUpdate]: new_cols: TTableSchemaColumns = {} for key, column in table["columns"].items(): if column.get("data_type") in ("timestamp", "time"): - if (prec := column.get("precision")) and prec > max_precision: - new_cols[key] = dict(column, precision=max_precision) # type: ignore[assignment] - + if prec := column.get("precision"): + # apply the arrow schema precision to dlt column schema + data_type = pyarrow.get_column_type_from_py_arrow(arrow_schema.field(key).type) + if data_type["data_type"] in ("timestamp", "time"): + prec = data_type["precision"] + # limit with destination precision + if prec > max_precision: + prec = max_precision + new_cols[key] = dict(column, precision=prec) # type: ignore[assignment] if not new_cols: return [] return [ @@ -345,8 +356,6 @@ def _fix_schema_precisions(self, root_table_name: str) -> List[TSchemaUpdate]: ] def __call__(self, extracted_items_file: str, root_table_name: str) -> List[TSchemaUpdate]: - base_schema_update = self._fix_schema_precisions(root_table_name) - # read schema and counts from file metadata from dlt.common.libs.pyarrow import get_parquet_metadata @@ -355,6 +364,9 @@ def __call__(self, extracted_items_file: str, root_table_name: str) -> List[TSch ) as f: num_rows, arrow_schema = get_parquet_metadata(f) file_metrics = DataWriterMetrics(extracted_items_file, num_rows, f.tell(), 0, 0) + # when parquet files is saved, timestamps will be truncated and coerced. take the updated values + # and apply them to dlt schema + base_schema_update = self._fix_schema_precisions(root_table_name, arrow_schema) add_dlt_id = self.config.parquet_normalizer.add_dlt_id add_dlt_load_id = self.config.parquet_normalizer.add_dlt_load_id diff --git a/dlt/normalize/normalize.py b/dlt/normalize/normalize.py index 0125d5a525..5e3315d10f 100644 --- a/dlt/normalize/normalize.py +++ b/dlt/normalize/normalize.py @@ -3,7 +3,8 @@ from typing import Callable, List, Dict, NamedTuple, Sequence, Tuple, Set, Optional from concurrent.futures import Future, Executor -from dlt.common import logger, sleep +from dlt.common import logger +from dlt.common.runtime.signals import sleep from dlt.common.configuration import with_config, known_sections from dlt.common.configuration.accessors import config from dlt.common.configuration.container import Container @@ -377,7 +378,9 @@ def spool_schema_files(self, load_id: str, schema: Schema, files: Sequence[str]) # delete existing folder for the case that this is a retry self.load_storage.new_packages.delete_package(load_id, not_exists_ok=True) # normalized files will go here before being atomically renamed - self.load_storage.new_packages.create_package(load_id) + self.load_storage.import_extracted_package( + load_id, self.normalize_storage.extracted_packages + ) logger.info(f"Created new load package {load_id} on loading volume") try: # process parallel @@ -391,7 +394,9 @@ def spool_schema_files(self, load_id: str, schema: Schema, files: Sequence[str]) ) # start from scratch self.load_storage.new_packages.delete_package(load_id) - self.load_storage.new_packages.create_package(load_id) + self.load_storage.import_extracted_package( + load_id, self.normalize_storage.extracted_packages + ) self.spool_files(load_id, schema.clone(update_normalizers=True), self.map_single, files) return load_id diff --git a/dlt/pipeline/__init__.py b/dlt/pipeline/__init__.py index 6b14eaf777..c9e7b5097c 100644 --- a/dlt/pipeline/__init__.py +++ b/dlt/pipeline/__init__.py @@ -2,7 +2,7 @@ from typing_extensions import TypeVar from dlt.common.schema import Schema -from dlt.common.schema.typing import TColumnSchema, TWriteDisposition, TSchemaContract +from dlt.common.schema.typing import TColumnSchema, TWriteDispositionConfig, TSchemaContract from dlt.common.typing import TSecretValue, Any from dlt.common.configuration import with_config @@ -201,7 +201,7 @@ def run( dataset_name: str = None, credentials: Any = None, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: Sequence[TColumnSchema] = None, schema: Schema = None, loader_file_format: TLoaderFileFormat = None, @@ -243,7 +243,9 @@ def run( * `@dlt.resource`: resource contains the full table schema and that includes the table name. `table_name` will override this property. Use with care! * `@dlt.source`: source contains several resources each with a table schema. `table_name` will override all table names within the source and load the data into single table. - write_disposition (Literal["skip", "append", "replace", "merge"], optional): Controls how to write data to a table. `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + write_disposition (TWriteDispositionConfig, optional): Controls how to write data to a table. Accepts a shorthand string literal or configuration dictionary. + Allowed shorthand string literals: `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + Write behaviour can be further customized through a configuration dictionary. For example, to obtain an SCD2 table provide `write_disposition={"disposition": "merge", "strategy": "scd2"}`. Please note that in case of `dlt.resource` the table schema value will be overwritten and in case of `dlt.source`, the values in all resources will be overwritten. columns (Sequence[TColumnSchema], optional): A list of column schemas. Typed dictionary describing column names, data types, write disposition and performance hints that gives you full control over the created table schema. diff --git a/dlt/pipeline/pipeline.py b/dlt/pipeline/pipeline.py index 00e45b96e7..bdade1308f 100644 --- a/dlt/pipeline/pipeline.py +++ b/dlt/pipeline/pipeline.py @@ -19,7 +19,9 @@ ) from dlt import version -from dlt.common import json, logger, pendulum +from dlt.common import logger +from dlt.common.json import json +from dlt.common.pendulum import pendulum from dlt.common.configuration import inject_section, known_sections from dlt.common.configuration.specs import RunConfiguration, CredentialsConfiguration from dlt.common.configuration.container import Container @@ -42,7 +44,7 @@ from dlt.common.schema.typing import ( TColumnNames, TSchemaTables, - TWriteDisposition, + TWriteDispositionConfig, TAnySchemaColumns, TSchemaContract, ) @@ -97,6 +99,7 @@ from dlt.common.schema import Schema from dlt.common.utils import is_interactive from dlt.common.warnings import deprecated, Dlt04DeprecationWarning +from dlt.common.versioned_state import json_encode_state, json_decode_state from dlt.extract import DltSource from dlt.extract.exceptions import SourceExhausted @@ -136,8 +139,6 @@ mark_state_extracted, migrate_pipeline_state, state_resource, - json_encode_state, - json_decode_state, default_pipeline_state, ) from dlt.pipeline.warnings import credentials_argument_deprecated @@ -393,7 +394,7 @@ def extract( *, table_name: str = None, parent_table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: TAnySchemaColumns = None, primary_key: TColumnNames = None, schema: Schema = None, @@ -561,7 +562,7 @@ def run( dataset_name: str = None, credentials: Any = None, table_name: str = None, - write_disposition: TWriteDisposition = None, + write_disposition: TWriteDispositionConfig = None, columns: TAnySchemaColumns = None, primary_key: TColumnNames = None, schema: Schema = None, @@ -605,7 +606,9 @@ def run( * `@dlt.resource`: resource contains the full table schema and that includes the table name. `table_name` will override this property. Use with care! * `@dlt.source`: source contains several resources each with a table schema. `table_name` will override all table names within the source and load the data into single table. - write_disposition (Literal["skip", "append", "replace", "merge"], optional): Controls how to write data to a table. `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + write_disposition (TWriteDispositionConfig, optional): Controls how to write data to a table. Accepts a shorthand string literal or configuration dictionary. + Allowed shorthand string literals: `append` will always add new data at the end of the table. `replace` will replace existing data with new data. `skip` will prevent data from loading. "merge" will deduplicate and merge data based on "primary_key" and "merge_key" hints. Defaults to "append". + Write behaviour can be further customized through a configuration dictionary. For example, to obtain an SCD2 table provide `write_disposition={"disposition": "merge", "strategy": "scd2"}`. Please note that in case of `dlt.resource` the table schema value will be overwritten and in case of `dlt.source`, the values in all resources will be overwritten. columns (Sequence[TColumnSchema], optional): A list of column schemas. Typed dictionary describing column names, data types, write disposition and performance hints that gives you full control over the created table schema. diff --git a/dlt/pipeline/platform.py b/dlt/pipeline/platform.py index 0955e91b51..fe419d5146 100644 --- a/dlt/pipeline/platform.py +++ b/dlt/pipeline/platform.py @@ -1,15 +1,16 @@ """Implements SupportsTracking""" from typing import Any, cast, TypedDict, List import requests -from dlt.common.managed_thread_pool import ManagedThreadPool from urllib.parse import urljoin -from dlt.pipeline.trace import PipelineTrace, PipelineStepTrace, TPipelineStep, SupportsPipeline -from dlt.common import json from dlt.common import logger +from dlt.common.json import json from dlt.common.pipeline import LoadInfo +from dlt.common.managed_thread_pool import ManagedThreadPool from dlt.common.schema.typing import TStoredSchema +from dlt.pipeline.trace import PipelineTrace, PipelineStepTrace, TPipelineStep, SupportsPipeline + _THREAD_POOL: ManagedThreadPool = ManagedThreadPool(1) TRACE_URL_SUFFIX = "/trace" STATE_URL_SUFFIX = "/state" diff --git a/dlt/pipeline/state_sync.py b/dlt/pipeline/state_sync.py index 5366b9c46d..d38010f842 100644 --- a/dlt/pipeline/state_sync.py +++ b/dlt/pipeline/state_sync.py @@ -1,18 +1,17 @@ -import binascii from copy import copy -from typing import Tuple, cast, List -import pendulum +from typing import Tuple, cast import dlt -from dlt.common import json +from dlt.common.pendulum import pendulum from dlt.common.typing import DictStrAny from dlt.common.schema.typing import STATE_TABLE_NAME, TTableSchemaColumns from dlt.common.destination.reference import WithStateSync, Destination -from dlt.common.utils import compressed_b64decode, compressed_b64encode from dlt.common.versioned_state import ( generate_state_version_hash, bump_state_version_if_modified, default_versioned_state, + compress_state, + decompress_state, ) from dlt.common.pipeline import TPipelineState @@ -39,27 +38,6 @@ } -def json_encode_state(state: TPipelineState) -> str: - return json.typed_dumps(state) - - -def json_decode_state(state_str: str) -> DictStrAny: - return json.typed_loads(state_str) # type: ignore[no-any-return] - - -def compress_state(state: TPipelineState) -> str: - return compressed_b64encode(json.typed_dumpb(state)) - - -def decompress_state(state_str: str) -> DictStrAny: - try: - state_bytes = compressed_b64decode(state_str) - except binascii.Error: - return json.typed_loads(state_str) # type: ignore[no-any-return] - else: - return json.typed_loadb(state_bytes) # type: ignore[no-any-return] - - def generate_pipeline_state_version_hash(state: TPipelineState) -> str: return generate_state_version_hash(state, exclude_attrs=["_local"]) diff --git a/dlt/pipeline/trace.py b/dlt/pipeline/trace.py index b610d1751f..fc15654949 100644 --- a/dlt/pipeline/trace.py +++ b/dlt/pipeline/trace.py @@ -7,7 +7,7 @@ from typing import Any, List, NamedTuple, Optional, Protocol, Sequence import humanize -from dlt.common import pendulum, json +from dlt.common.pendulum import pendulum from dlt.common.configuration import is_secret_hint from dlt.common.configuration.exceptions import ContextDefaultCannotBeCreated from dlt.common.configuration.specs.config_section_context import ConfigSectionContext diff --git a/dlt/pipeline/track.py b/dlt/pipeline/track.py index 990c59050e..e6f8db36d6 100644 --- a/dlt/pipeline/track.py +++ b/dlt/pipeline/track.py @@ -3,7 +3,8 @@ from typing import Any import humanize -from dlt.common import pendulum, logger +from dlt.common import logger +from dlt.common.pendulum import pendulum from dlt.common.utils import digest128 from dlt.common.runtime.exec_info import github_info from dlt.common.runtime.segment import track as dlthub_telemetry_track diff --git a/dlt/sources/helpers/rest_client/auth.py b/dlt/sources/helpers/rest_client/auth.py index 99421e2c60..620135d410 100644 --- a/dlt/sources/helpers/rest_client/auth.py +++ b/dlt/sources/helpers/rest_client/auth.py @@ -12,19 +12,19 @@ Iterable, TYPE_CHECKING, ) -from dlt.sources.helpers import requests from requests.auth import AuthBase from requests import PreparedRequest # noqa: I251 -import pendulum - -from dlt.common.exceptions import MissingDependencyException from dlt.common import logger +from dlt.common.exceptions import MissingDependencyException from dlt.common.configuration.specs.base_configuration import configspec from dlt.common.configuration.specs import CredentialsConfiguration from dlt.common.configuration.specs.exceptions import NativeValueError +from dlt.common.pendulum import pendulum from dlt.common.typing import TSecretStrValue +from dlt.sources.helpers import requests + if TYPE_CHECKING: from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes else: diff --git a/dlt/sources/helpers/rest_client/client.py b/dlt/sources/helpers/rest_client/client.py index 1c3e4d97b6..03b785c9f0 100644 --- a/dlt/sources/helpers/rest_client/client.py +++ b/dlt/sources/helpers/rest_client/client.py @@ -10,12 +10,12 @@ ) import copy from urllib.parse import urlparse - from requests import Session as BaseSession # noqa: I251 from requests import Response, Request -from dlt.common import logger from dlt.common import jsonpath +from dlt.common import logger + from dlt.sources.helpers.requests.retry import Client from .typing import HTTPMethodBasic, HTTPMethod, Hooks diff --git a/dlt/sources/helpers/transform.py b/dlt/sources/helpers/transform.py index d038ec58ee..32843e2aa2 100644 --- a/dlt/sources/helpers/transform.py +++ b/dlt/sources/helpers/transform.py @@ -110,3 +110,37 @@ def _transformer(item: TDataItem) -> TDataItem: return item return _transformer + + +def add_row_hash_to_table(row_hash_column_name: str) -> TDataItem: + """Computes content hash for each row of panda frame, arrow table or batch and adds it as `row_hash_column_name` column. + + Internally arrow tables and batches are converted to pandas DataFrame and then `hash_pandas_object` is used to + generate a series with row hashes. Hashes are converted to signed int64 and added to original table. Data may be modified. + For SCD2 use with a resource configuration that assigns custom row version column to `row_hash_column_name` + """ + from dlt.common.libs import pyarrow + from dlt.common.libs.pyarrow import pyarrow as pa + from dlt.common.libs.pandas import pandas as pd + + def _unwrap(table: TDataItem) -> TDataItem: + if is_arrow := pyarrow.is_arrow_item(table): + df = table.to_pandas(deduplicate_objects=False) + else: + df = table + + hash_ = pd.util.hash_pandas_object(df) + + if is_arrow: + table = pyarrow.append_column( + table, + row_hash_column_name, + pa.Array.from_pandas(hash_, type=pa.int64(), safe=False), + ) + else: + hash_np = hash_.values.astype("int64", copy=False, casting="unsafe") + table[row_hash_column_name] = hash_np + + return table + + return _unwrap diff --git a/dlt/version.py b/dlt/version.py index f8ca3cb873..aa87021bf7 100644 --- a/dlt/version.py +++ b/dlt/version.py @@ -14,7 +14,7 @@ def get_installed_requirement_string(package: str = DLT_PKG_NAME) -> str: # PEP 610 https://packaging.python.org/en/latest/specifications/direct-url/#specification direct_url = dist.read_text("direct_url.json") if direct_url is not None: - from dlt.common import json + from dlt.common.json import json # `url` contain the location of the distribution url = urlparse(json.loads(direct_url)["url"]) diff --git a/docs/examples/nested_data/nested_data.py b/docs/examples/nested_data/nested_data.py index afda16a51a..046e566efd 100644 --- a/docs/examples/nested_data/nested_data.py +++ b/docs/examples/nested_data/nested_data.py @@ -24,7 +24,7 @@ from bson.decimal128 import Decimal128 from bson.objectid import ObjectId -from pendulum import _datetime +from pendulum import _datetime # noqa: I251 from pymongo import MongoClient import dlt diff --git a/docs/tools/lint_setup/template.py b/docs/tools/lint_setup/template.py index c72c4dba62..bebc0e9ab0 100644 --- a/docs/tools/lint_setup/template.py +++ b/docs/tools/lint_setup/template.py @@ -7,12 +7,11 @@ import os -import pendulum from datetime import datetime # noqa: I251 -from pendulum import DateTime +from pendulum import DateTime # noqa: I251 import dlt -from dlt.common import json +from dlt.common import json, pendulum from dlt.common.typing import TimedeltaSeconds, TAnyDateTime, TDataItem, TDataItems from dlt.common.schema.typing import TTableSchema, TTableSchemaColumns diff --git a/docs/website/blog/2024-03-11-moving-away-from-segment.md b/docs/website/blog/2024-03-11-moving-away-from-segment.md index 4f4b7d0a80..e3e44ce027 100644 --- a/docs/website/blog/2024-03-11-moving-away-from-segment.md +++ b/docs/website/blog/2024-03-11-moving-away-from-segment.md @@ -10,7 +10,7 @@ authors: tags: [Pub/Sub, dlt, Segment, Streaming] --- :::info -TL;DR: This blog post introduces a cost-effective solution for event streaming that results in up to 18x savings. The solution leverages Cloud Pub/Sub and DLT to build an efficient event streaming pipeline. +TL;DR: This blog post introduces a cost-effective solution for event streaming that results in up to 18x savings. The solution leverages Cloud Pub/Sub and dlt to build an efficient event streaming pipeline. ::: ## The Segment Problem @@ -18,19 +18,19 @@ Event tracking is a complicated problem for which there exist many solutions. On :::note -💡 With Segment, you pay 1-1.2 cents for every tracked users. +💡 With Segment, you pay 1-1.2 cents for every tracked users. Let’s take a back-of-napkin example: for 100.000 users, ingesting their events data would cost **$1000.** **The bill:** -* **Minimum 10,000 monthly tracked users (0-10K)** + $120. +* **Minimum 10,000 monthly tracked users (0-10K)** + $120. * **Additional 1,000 monthly tracked users (10K - 25K)** + $12 / 1000 user. * **Additional 1,000 monthly tracked users (25k - 100K)** + $11 / 1000 user. * **Additional 1,000 monthly tracked users (100k +)** + $10 / 1000 user. ::: -The price of **$1000/month** for 100k tracked users doesn’t seem excessive, given the complexity of the task at hand. +The price of **$1000/month** for 100k tracked users doesn’t seem excessive, given the complexity of the task at hand. However, similar results can be achieved on GCP by combining different services. If those 100k users produce 1-2m events, **those costs would stay in the $10-60 range.** @@ -45,18 +45,18 @@ Our proposed solution to replace Segment involves using dlt with Cloud Pub/Sub t In this architecture, a publisher initiates the process by pushing events to a Pub/Sub topic. Specifically, in the context of dlt, the library acts as the publisher, directing user telemetry data to a designated topic within Pub/Sub. -A subscriber is attached to the topic. Pub/Sub offers a push-based [subscriber](https://cloud.google.com/pubsub/docs/subscription-overview) that proactively receives messages from the topic and writes them to Cloud Storage. The subscriber is configured to aggregate all messages received within a 10-minute window and then forward them to a designated storage bucket. +A subscriber is attached to the topic. Pub/Sub offers a push-based [subscriber](https://cloud.google.com/pubsub/docs/subscription-overview) that proactively receives messages from the topic and writes them to Cloud Storage. The subscriber is configured to aggregate all messages received within a 10-minute window and then forward them to a designated storage bucket. Once the data is written to the Cloud Storage this triggers a Cloud Function. The Cloud Function reads the data from the storage bucket and uses dlt to ingest the data into BigQuery. ## Code Walkthrough -This section dives into a comprehensive code walkthrough that illustrates the step-by-step process of implementing our proposed event streaming pipeline. +This section dives into a comprehensive code walkthrough that illustrates the step-by-step process of implementing our proposed event streaming pipeline. Implementing the pipeline requires the setup of various resources, including storage buckets and serverless functions. To streamline the procurement of these resources, we'll leverage Terraform—an Infrastructure as Code (IaC) tool. ### Prerequisites -Before we embark on setting up the pipeline, there are essential tools that need to be installed to ensure a smooth implementation process. +Before we embark on setting up the pipeline, there are essential tools that need to be installed to ensure a smooth implementation process. - **Firstly**, follow the official guide to install [Terraform](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli), a tool for automating the deployment of cloud infrastructure. - **Secondly**, install the [Google Cloud Pub/Sub API client library](https://cloud.google.com/sdk/docs/install) which is required for publishing events to Cloud Pub/Sub. @@ -93,7 +93,7 @@ To set up our pipeline, start by cloning the [GitHub Repository](https://github. │ └── variables.tf ``` -Within this structure, the **Terraform** directory houses all the Terraform code required to set up the necessary resources on Google Cloud. +Within this structure, the **Terraform** directory houses all the Terraform code required to set up the necessary resources on Google Cloud. Meanwhile, the **cloud_functions** folder includes the code for the Cloud Function that will be deployed. This function will read the data from storage and use dlt to ingest data into BigQuery. The code for the function can be found in `cloud_functions/main.py` file. @@ -133,7 +133,7 @@ variable "service_account_email" { ### Step 3: Procure Cloud Resources -We are now ready to set up some cloud resources. To get started, navigate into the **terraform** directory and `terraform init`. The command initializes the working directory containing Terraform configuration files. +We are now ready to set up some cloud resources. To get started, navigate into the **terraform** directory and `terraform init`. The command initializes the working directory containing Terraform configuration files. With the initialization complete, you're ready to proceed with the creation of your cloud resources. To do this, run the following Terraform commands in sequence. These commands instruct Terraform to plan and apply the configurations defined in your `.tf` files, setting up the infrastructure on Google Cloud as specified. @@ -174,7 +174,7 @@ python publisher.py ### Step 5: Results -Once the publisher sends events to the Pub/Sub Topic, the pipeline is activated. These are asynchronous calls, so there's a delay between message publication and their appearance in BigQuery. +Once the publisher sends events to the Pub/Sub Topic, the pipeline is activated. These are asynchronous calls, so there's a delay between message publication and their appearance in BigQuery. The average completion time of the pipeline is approximately 12 minutes, accounting for the 10-minute time interval after which the subscriber pushes data to storage plus the Cloud Function execution time. The push interval of the subscriber can be adjusted by changing the **max_duration** in `pubsub.tf` @@ -197,7 +197,7 @@ On average the cost for our proposed pipeline are as follows: - Our web tracking user:event ratio is 1:15, so the Segment cost equivalent would be **$55**. - Our telemetry device:event ratio is 1:60, so the Segment cost equivalent would be **$220**. -So with our setup, as long as we keep events-to-user ratio **under 270**, we will have cost savings over Segment. In reality, it gets even better because GCP offers a very generous free tier that resets every month, where Segment costs more at low volumes. +So with our setup, as long as we keep events-to-user ratio **under 270**, we will have cost savings over Segment. In reality, it gets even better because GCP offers a very generous free tier that resets every month, where Segment costs more at low volumes. **GCP Cost Calculation:** Currently, our telemetry tracks 50,000 anonymized devices each month on a 1:60 device-to-event ratio. Based on these data volumes we can estimate the cost of our proposed pipeline. diff --git a/docs/website/blog/2024-03-25-reverse_etl_dlt.md b/docs/website/blog/2024-03-25-reverse_etl_dlt.md index 19785c6e66..2dc32cc914 100644 --- a/docs/website/blog/2024-03-25-reverse_etl_dlt.md +++ b/docs/website/blog/2024-03-25-reverse_etl_dlt.md @@ -62,7 +62,7 @@ Building a destination: [docs](https://dlthub.com/devel/dlt-ecosystem/destinatio SQL source: [docs](https://dlthub.com/devel/dlt-ecosystem/verified-sources/sql_database) In this example, you will see why it’s faster to build a custom destination than set up a separate tool. -DLT allows you to define custom destination functions. You'll write a function that extracts the relevant data from your dataframe and formats it for the Notion API. +dlt allows you to define custom destination functions. You'll write a function that extracts the relevant data from your dataframe and formats it for the Notion API. This example assumes you have set up Google Sheets API access and obtained the necessary credentials to authenticate. diff --git a/docs/website/blog/2024-03-26-second-data-setup b/docs/website/blog/2024-03-26-second-data-setup new file mode 100644 index 0000000000..12b032eef2 --- /dev/null +++ b/docs/website/blog/2024-03-26-second-data-setup @@ -0,0 +1,123 @@ +--- +slug: second-data-setup +title: The Second Data Warehouse, aka the "disaster recovery" project +image: https://storage.googleapis.com/dlt-blog-images/second_house.png +authors: + name: Adrian Brudaru + title: Open source Data Engineer + url: https://github.com/adrianbr + image_url: https://avatars.githubusercontent.com/u/5762770?v=4 +tags: [data setup, disaster recovery] +--- + +# The things i've seen + +The last 5 years before working on dlt, I spent as a data engineering freelancer. +Before freelancing, I was working for "sexy but poor" startups where building fast and cheap was a religion. + +In this time, I had the pleasure of doing many first time setups, and a few "rebuilds" or "second time setups". + +In fact, my first freelancing project was a "disaster recovery" one. + +A "second time build" or "disaster recovery project" refers to the process of re-designing, re-building, or significantly +overhauling a data warehouse or data infrastructure after the initial setup has failed to meet the organization's needs. + +![dipping your toes in disaster](https://storage.googleapis.com/dlt-blog-images/disaster-2.png) + +## The first time builds gone wrong + +There's usually no need for a second time build, if the first time build works. Rather, a migration might cut it. +A second time build usually happens only if +- the first time build does not work, either now or for the next requirements. +- the first time build cannot be "migrated" or "fixed" due to fundamental flaws. + +Let's take some examples from my experiences. +Example 1: A serial talker takes a lead role at a large, growing startup. They speak like management, so management trusts. A few years later + - half the pipelines are running on Pentaho + windows, the other are python 2, 3 and written by agencies. + - The data engineering team quit. They had enough. + - The remaining data engineers do what they want - a custom framework - or they threaten to quit, taking the only knowledge of the pipelines with them. + - Solution: Re-write all pipelines in python3, replace custom framework with airflow, add tests, github, and other best pratices. + +Example 2: A large international manufacturing company needed a data warehouse. + - Microsoft sold them their tech+ consultants. + - 2 years later, it's done but doesn't work (query time impossible) + - Solution: Teach the home DE team to use redshift and migrate. + +Example 3: A non technical professional takes a lead data role and uses a tool to do everything. + - same as above but the person also hired a team of juniors + - since there was no sudden ragequit, the situation persisted for a few years + - after they left, the remaining team removed the tool and re-built. + +Example 4: A first time data hire introduces a platform-like tool that's sql centric and has no versioning, api, or programmatic control. + - after writing 30k+ lines of wet sql, scheduling and making them dependent on each other in this UI tool (without lineage), the person can no longer maintain the reports + - Quits after arguing with management. + - Solution: Reverse engineer existing reports, account for bugs and unfulfilled requirements, build them from scratch, occasionally searching the mass of sql. Outcome was under 2k lines. + +Example 5: A VC company wants to make a tool that reads metrics from business apps like google ads, Stripe. + - They end up at the largest local agency, who recommends them a single - tenant SaaS MDS for 90k to set up and a pathway from there + - They agreed and then asked me to review. The agency person was aggressive and queried my knowledge on unrelated things, in an attempt to dismiss my assessment. + - Turns out the agency was selling "installing 5tran and cleaning the data" for 5k+ per source, and some implementation partners time. + - I think the VC later hired a non technical freelancer to do the work. + +# Who can build a first time setup that scales into the future? + +The non-negotiable skills needed are +- Programming. You can use ETL tools for ingestion, but they rarely solve the problem fully (under 20% in my respondent network - these are generally <30 people companies) +- Modelling. Architecture first, sql second, tools third. +- Requirement collection. You should consult your stakeholders on the data available to represent their process, and reach a good result. Usually the stakeholders are not experts and will not be able to give good requirements. + +## Who's to blame and what can we do about it? + +I believe the blame is quite shared. The common denominators seem to be +- A lack of technical knowledge, +- tools to fill the gap. +- and a warped or dishonest self representation (by vendor or professional) + +As for what to do about it: +If you were a hiring manager, ensure that your first data hire has all the skills at their disposal, and make sure they don't just talk the talk but walk the walk. Ask for references or test them. + +But you aren't a hiring manager - those folks don't read data blogs. + +So here's what you can do +- Ensure all 3 skills are available - they do not need to all be in one person. You could hire a freelance DE to build first, and a technical analyst to fulfil requests and extend the stack. +- Let vendors write about first data hire, and "follow the money" - Check if the advice aligns with their financial incentive. If it does, get a second opinion. +- Choose tooling that scales across different stages of a data stack lifecycle, so the problem doesn't occur. +- Use vendor agnostic components where possible (for example, dlt + sqlmesh + sql glot can create a db-agnostic stack that enables you to switch between dbs) +- Behave better - the temptation to oversell yourself is there, but you could check yourself and look for a position where you can learn. Your professional network could be your biggest help in your career, don't screw them over. +- Use independent freelancers for consulting. They live off reputation, so look for the recommended ones. + +## How to do a disaster recovery? + +The problem usually originates from the lack of a skill, which downstreams into implementations that don't scale. +However, the solution is often not as simple as adding the skill, because various workarounds were created to bridge that gap, and those workarounds have people working on them. + +Simply adding that missing skill to the team to build the missing piece would create a redundancy, which in its resolution would kick out the existing workarounds. +But workarounds are maintained by roles, so the original implementer will usually feel their position threatened; +This can easily escalate to a people conflict which often leads with the workaround maker quitting (or getting fired). + +How to manage the emotions? +- Be considerate of people's feelings - you are brought in to replace their work, so make it a cooperative experience where they can be the hero. +- Ask for help when you are not sure about who has the decision over an area. + +How to manage the technical side? +- Ensure you have all the skills needed to deliver a data stack on the team. +- If the existing solution produces correct results, use it as requirements for the next - for example, you could write tests that check that business rules are correctly implemented. +- Clarify with stakeholders how much the old solution should be maintained - it will likely free up people to work on the new one. +- Identify team skills that can help towards the new solution and consider them when choosing the technology stack. + + +## What I wish I knew + +Each "disaster recovery" project was more than just a technical reboot; it was a testament to the team's adaptability, +the foresight in planning for scalability, and, importantly, the humility to recognize and rectify mistakes. +"What I Wish I Knew Then" is about the understanding that building a data infrastructure is as much about +building a culture of continuous learning and improvement as it is about the code and systems themselves. + + +### Want to discuss? + +Agencies and freelancers are often the heavy-lifters that are brought in to do such setups. +Is this something you are currently doing? +Tell us about your challenges so we may better support you. + +[Join our slack community](https://dlthub.com/community) to take part in the conversation. \ No newline at end of file diff --git a/docs/website/docs/dlt-ecosystem/destinations/athena.md b/docs/website/docs/dlt-ecosystem/destinations/athena.md index 7f10519c20..96edfb3d70 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/athena.md +++ b/docs/website/docs/dlt-ecosystem/destinations/athena.md @@ -9,7 +9,7 @@ keywords: [aws, athena, glue catalog] The Athena destination stores data as Parquet files in S3 buckets and creates [external tables in AWS Athena](https://docs.aws.amazon.com/athena/latest/ug/creating-tables.html). You can then query those tables with Athena SQL commands, which will scan the entire folder of Parquet files and return the results. This destination works very similarly to other SQL-based destinations, with the exception that the merge write disposition is not supported at this time. The `dlt` metadata will be stored in the same bucket as the Parquet files, but as iceberg tables. Athena also supports writing individual data tables as Iceberg tables, so they may be manipulated later. A common use case would be to strip GDPR data from them. ## Install dlt with Athena -**To install the DLT library with Athena dependencies:** +**To install the dlt library with Athena dependencies:** ```sh pip install dlt[athena] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/bigquery.md b/docs/website/docs/dlt-ecosystem/destinations/bigquery.md index 1e80146a7a..54d5abae6d 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/bigquery.md +++ b/docs/website/docs/dlt-ecosystem/destinations/bigquery.md @@ -8,7 +8,7 @@ keywords: [bigquery, destination, data warehouse] ## Install dlt with BigQuery -**To install the DLT library with BigQuery dependencies:** +**To install the dlt library with BigQuery dependencies:** ```sh pip install dlt[bigquery] diff --git a/docs/website/docs/dlt-ecosystem/destinations/databricks.md b/docs/website/docs/dlt-ecosystem/destinations/databricks.md index c852cbcc7c..b601809935 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/databricks.md +++ b/docs/website/docs/dlt-ecosystem/destinations/databricks.md @@ -10,7 +10,7 @@ keywords: [Databricks, destination, data warehouse] *Big thanks to Evan Phillips and [swishbi.com](https://swishbi.com/) for contributing code, time, and a test environment.* ## Install dlt with Databricks -**To install the DLT library with Databricks dependencies:** +**To install the dlt library with Databricks dependencies:** ```sh pip install dlt[databricks] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/dremio.md b/docs/website/docs/dlt-ecosystem/destinations/dremio.md index deb5947a06..0be01e8e32 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/dremio.md +++ b/docs/website/docs/dlt-ecosystem/destinations/dremio.md @@ -7,7 +7,7 @@ keywords: [dremio, iceberg, aws, glue catalog] # Dremio ## Install dlt with Dremio -**To install the DLT library with Dremio and s3 dependencies:** +**To install the dlt library with Dremio and s3 dependencies:** ```sh pip install dlt[dremio,s3] ``` @@ -86,7 +86,7 @@ Data loading happens by copying a staged parquet files from an object storage bu Dremio does not support `CREATE SCHEMA` DDL statements. -Therefore, "Metastore" data sources, such as Hive or Glue, require that the dataset schema exists prior to running the DLT pipeline. `full_refresh=True` is unsupported for these data sources. +Therefore, "Metastore" data sources, such as Hive or Glue, require that the dataset schema exists prior to running the dlt pipeline. `full_refresh=True` is unsupported for these data sources. "Object Storage" data sources do not have this limitation. diff --git a/docs/website/docs/dlt-ecosystem/destinations/duckdb.md b/docs/website/docs/dlt-ecosystem/destinations/duckdb.md index 79e26554f6..e4f8732507 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/duckdb.md +++ b/docs/website/docs/dlt-ecosystem/destinations/duckdb.md @@ -7,7 +7,7 @@ keywords: [duckdb, destination, data warehouse] # DuckDB ## Install dlt with DuckDB -**To install the DLT library with DuckDB dependencies, run:** +**To install the dlt library with DuckDB dependencies, run:** ```sh pip install dlt[duckdb] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md index 4f7a924be1..a8b2b084b9 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md +++ b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md @@ -6,7 +6,7 @@ Its primary role is to be used as a staging for other destinations, but you can > 💡 Please read the notes on the layout of the data files. Currently, we are getting feedback on it. Please join our Slack (icon at the top of the page) and help us find the optimal layout. ## Install dlt with filesystem -**To install the DLT library with filesystem dependencies:** +**To install the dlt library with filesystem dependencies:** ```sh pip install dlt[filesystem] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/motherduck.md b/docs/website/docs/dlt-ecosystem/destinations/motherduck.md index 1c80f7be9b..f6fcdfbc0c 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/motherduck.md +++ b/docs/website/docs/dlt-ecosystem/destinations/motherduck.md @@ -8,7 +8,7 @@ keywords: [MotherDuck, duckdb, destination, data warehouse] > 🧪 MotherDuck is still invitation-only and is being intensively tested. Please see the limitations/problems at the end. ## Install dlt with MotherDuck -**To install the DLT library with MotherDuck dependencies:** +**To install the dlt library with MotherDuck dependencies:** ```sh pip install dlt[motherduck] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/mssql.md b/docs/website/docs/dlt-ecosystem/destinations/mssql.md index c0bf2bcebf..a63044bd73 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/mssql.md +++ b/docs/website/docs/dlt-ecosystem/destinations/mssql.md @@ -7,7 +7,7 @@ keywords: [mssql, sqlserver, destination, data warehouse] # Microsoft SQL Server ## Install dlt with MS SQL -**To install the DLT library with MS SQL dependencies, use:** +**To install the dlt library with MS SQL dependencies, use:** ```sh pip install dlt[mssql] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/postgres.md b/docs/website/docs/dlt-ecosystem/destinations/postgres.md index b806ba78fe..95f45b6a1c 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/postgres.md +++ b/docs/website/docs/dlt-ecosystem/destinations/postgres.md @@ -7,7 +7,7 @@ keywords: [postgres, destination, data warehouse] # Postgres ## Install dlt with PostgreSQL -**To install the DLT library with PostgreSQL dependencies, run:** +**To install the dlt library with PostgreSQL dependencies, run:** ```sh pip install dlt[postgres] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/qdrant.md b/docs/website/docs/dlt-ecosystem/destinations/qdrant.md index 7711e7d877..1b560ad6fe 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/qdrant.md +++ b/docs/website/docs/dlt-ecosystem/destinations/qdrant.md @@ -95,7 +95,7 @@ It accepts the following arguments: - `data`: a dlt resource object or a Python data structure (e.g., a list of dictionaries). - `embed`: a name of the field or a list of names to generate embeddings for. -Returns: [DLT resource](../../general-usage/resource.md) object that you can pass to the `pipeline.run()`. +Returns: [dlt resource](../../general-usage/resource.md) object that you can pass to the `pipeline.run()`. Example: diff --git a/docs/website/docs/dlt-ecosystem/destinations/redshift.md b/docs/website/docs/dlt-ecosystem/destinations/redshift.md index a6445b6a5c..349698d201 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/redshift.md +++ b/docs/website/docs/dlt-ecosystem/destinations/redshift.md @@ -7,7 +7,7 @@ keywords: [redshift, destination, data warehouse] # Amazon Redshift ## Install dlt with Redshift -**To install the DLT library with Redshift dependencies:** +**To install the dlt library with Redshift dependencies:** ```sh pip install dlt[redshift] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/snowflake.md b/docs/website/docs/dlt-ecosystem/destinations/snowflake.md index e3a78422c6..8ba6934313 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/snowflake.md +++ b/docs/website/docs/dlt-ecosystem/destinations/snowflake.md @@ -7,7 +7,7 @@ keywords: [Snowflake, destination, data warehouse] # Snowflake ## Install dlt with Snowflake -**To install the DLT library with Snowflake dependencies, run:** +**To install the dlt library with Snowflake dependencies, run:** ```sh pip install dlt[snowflake] ``` diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index ff46efb272..d1c7d36aa2 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -7,7 +7,7 @@ keywords: [synapse, destination, data warehouse] # Synapse ## Install dlt with Synapse -**To install the DLT library with Synapse dependencies:** +**To install the dlt library with Synapse dependencies:** ```sh pip install dlt[synapse] ``` diff --git a/docs/website/docs/dlt-ecosystem/verified-sources/google_sheets.md b/docs/website/docs/dlt-ecosystem/verified-sources/google_sheets.md index 3be72adfa0..7b957e98ea 100644 --- a/docs/website/docs/dlt-ecosystem/verified-sources/google_sheets.md +++ b/docs/website/docs/dlt-ecosystem/verified-sources/google_sheets.md @@ -604,6 +604,4 @@ def get_named_ranges(): tasks.add_run(pipeline, google_spreadsheet("1HhWHjqouQnnCIZAFa2rL6vT91YRN8aIhts22SUUR580"), decompose="none", trigger_rule="all_done", retries=0, provide_context=True) ``` -Enjoy the DLT Google Sheets pipeline experience! - diff --git a/docs/website/docs/dlt-ecosystem/verified-sources/strapi.md b/docs/website/docs/dlt-ecosystem/verified-sources/strapi.md index caf5ae2359..a9d70c338c 100644 --- a/docs/website/docs/dlt-ecosystem/verified-sources/strapi.md +++ b/docs/website/docs/dlt-ecosystem/verified-sources/strapi.md @@ -36,7 +36,7 @@ Sources and resources that can be loaded using this verified source are: 1. Fill in Name, Description, and Duration. 1. Choose a token type: Read Only, Full Access, or custom (with find and findOne selected). 1. Save to view your API token. -1. Copy it for DLT secrets setup. +1. Copy it for dlt secrets setup. > Note: The Strapi UI, which is described here, might change. > The full guide is available at [this link.](https://docs.strapi.io/user-docs/settings/API-tokens) diff --git a/docs/website/docs/general-usage/customising-pipelines/removing_columns.md b/docs/website/docs/general-usage/customising-pipelines/removing_columns.md index 3163062ced..8808d1f1a5 100644 --- a/docs/website/docs/general-usage/customising-pipelines/removing_columns.md +++ b/docs/website/docs/general-usage/customising-pipelines/removing_columns.md @@ -78,7 +78,7 @@ Let's create a sample pipeline demonstrating the process of removing a column. 1. At last, create a pipeline: ```py - # Integrating with a DLT pipeline + # Integrating with a dlt pipeline pipeline = dlt.pipeline( pipeline_name='example', destination='bigquery', diff --git a/docs/website/docs/general-usage/incremental-loading.md b/docs/website/docs/general-usage/incremental-loading.md index 23b2218b46..28d2f862b2 100644 --- a/docs/website/docs/general-usage/incremental-loading.md +++ b/docs/website/docs/general-usage/incremental-loading.md @@ -48,7 +48,13 @@ dataset with the merge write disposition. ## Merge incremental loading -The `merge` write disposition is used in two scenarios: +The `merge` write disposition can be used with two different strategies: +1) `delete-insert` (default strategy) +2) `scd2` + +### `delete-insert` strategy + +The default `delete-insert` strategy is used in two scenarios: 1. You want to keep only one instance of certain record i.e. you receive updates of the `user` state from an API and want to keep just one record per `user_id`. @@ -56,7 +62,7 @@ The `merge` write disposition is used in two scenarios: instance of a record for each batch even in case you load an old batch or load the current batch several times a day (i.e. to receive "live" updates). -The `merge` write disposition loads data to a `staging` dataset, deduplicates the staging data if a +The `delete-insert` strategy loads data to a `staging` dataset, deduplicates the staging data if a `primary_key` is provided, deletes the data from the destination using `merge_key` and `primary_key`, and then inserts the new records. All of this happens in a single atomic transaction for a parent and all child tables. @@ -126,7 +132,7 @@ def github_repo_events(last_created_at = dlt.sources.incremental("created_at", " yield from _get_rest_pages("events") ``` -### Delete records +#### Delete records The `hard_delete` column hint can be used to delete records from the destination dataset. The behavior of the delete mechanism depends on the data type of the column marked with the hint: 1) `bool` type: only `True` leads to a delete—`None` and `False` values are disregarded 2) other types: each `not None` value leads to a delete @@ -135,7 +141,7 @@ Each record in the destination table with the same `primary_key` or `merge_key` Deletes are propagated to any child table that might exist. For each record that gets deleted in the root table, all corresponding records in the child table(s) will also be deleted. Records in parent and child tables are linked through the `root key` that is explained in the next section. -#### Example: with primary key and boolean delete column +##### Example: with primary key and boolean delete column ```py @dlt.resource( primary_key="id", @@ -158,7 +164,7 @@ def resource(): ... ``` -#### Example: with merge key and non-boolean delete column +##### Example: with merge key and non-boolean delete column ```py @dlt.resource( merge_key="id", @@ -176,7 +182,7 @@ def resource(): ... ``` -#### Example: with primary key and "dedup_sort" hint +##### Example: with primary key and "dedup_sort" hint ```py @dlt.resource( primary_key="id", @@ -198,7 +204,7 @@ def resource(): ... ``` -### Forcing root key propagation +#### Forcing root key propagation Merge write disposition requires that the `_dlt_id` of top level table is propagated to child tables. This concept is similar to foreign key which references a parent table, and we call it a @@ -230,6 +236,136 @@ In example above we enforce the root key propagation with `fb_ads.root_key = Tru that correct data is propagated on initial `replace` load so the future `merge` load can be executed. You can achieve the same in the decorator `@dlt.source(root_key=True)`. +### `scd2` strategy +`dlt` can create [Slowly Changing Dimension Type 2](https://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2:_add_new_row) (SCD2) destination tables for dimension tables that change in the source. The resource is expected to provide a full extract of the source table each run. A row hash is stored in `_dlt_id` and used as surrogate key to identify source records that have been inserted, updated, or deleted. A high timestamp (9999-12-31 00:00:00.000000) is used to indicate an active record. + +#### Example: `scd2` merge strategy +```py +@dlt.resource( + write_disposition={"disposition": "merge", "strategy": "scd2"} +) +def dim_customer(): + # initial load + yield [ + {"customer_key": 1, "c1": "foo", "c2": 1}, + {"customer_key": 2, "c1": "bar", "c2": 2} + ] + +pipeline.run(dim_customer()) # first run — 2024-04-09 18:27:53.734235 +... +``` + +*`dim_customer` destination table after first run—inserted two records present in initial load and added validity columns:* + +| `_dlt_valid_from` | `_dlt_valid_to` | `customer_key` | `c1` | `c2` | +| -- | -- | -- | -- | -- | +| 2024-04-09 18:27:53.734235 | 9999-12-31 00:00:00.000000 | 1 | foo | 1 | +| 2024-04-09 18:27:53.734235 | 9999-12-31 00:00:00.000000 | 2 | bar | 2 | + +```py +... +def dim_customer(): + # second load — record for customer_key 1 got updated + yield [ + {"customer_key": 1, "c1": "foo_updated", "c2": 1}, + {"customer_key": 2, "c1": "bar", "c2": 2} +] + +pipeline.run(dim_customer()) # second run — 2024-04-09 22:13:07.943703 +``` + +*`dim_customer` destination table after second run—inserted new record for `customer_key` 1 and retired old record by updating `_dlt_valid_to`:* + +| `_dlt_valid_from` | `_dlt_valid_to` | `customer_key` | `c1` | `c2` | +| -- | -- | -- | -- | -- | +| 2024-04-09 18:27:53.734235 | **2024-04-09 22:13:07.943703** | 1 | foo | 1 | +| 2024-04-09 18:27:53.734235 | 9999-12-31 00:00:00.000000 | 2 | bar | 2 | +| **2024-04-09 22:13:07.943703** | **9999-12-31 00:00:00.000000** | **1** | **foo_updated** | **1** | + +```py +... +def dim_customer(): + # third load — record for customer_key 2 got deleted + yield [ + {"customer_key": 1, "c1": "foo_updated", "c2": 1}, + ] + +pipeline.run(dim_customer()) # third run — 2024-04-10 06:45:22.847403 +``` + +*`dim_customer` destination table after third run—retired deleted record by updating `_dlt_valid_to`:* + +| `_dlt_valid_from` | `_dlt_valid_to` | `customer_key` | `c1` | `c2` | +| -- | -- | -- | -- | -- | +| 2024-04-09 18:27:53.734235 | 2024-04-09 22:13:07.943703 | 1 | foo | 1 | +| 2024-04-09 18:27:53.734235 | **2024-04-10 06:45:22.847403** | 2 | bar | 2 | +| 2024-04-09 22:13:07.943703 | 9999-12-31 00:00:00.000000 | 1 | foo_updated | 1 | + +#### Example: customize validity column names +`_dlt_valid_from` and `_dlt_valid_to` are used by default as validity column names. Other names can be configured as follows: +```py +@dlt.resource( + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "validity_column_names": ["from", "to"], # will use "from" and "to" instead of default values + } +) +def dim_customer(): + ... +... +``` + +#### Example: use your own row hash +By default, `dlt` generates a row hash based on all columns provided by the resource and stores it in `_dlt_id`. You can use your own hash instead by specifying `row_version_column_name` in the `write_disposition` dictionary. You might already have a column present in your resource that can naturally serve as row hash, in which case it's more efficient to use those pre-existing hash values than to generate new artificial ones. This option also allows you to use hashes based on a subset of columns, in case you want to ignore changes in some of the columns. When using your own hash, values for `_dlt_id` are randomly generated. +```py +@dlt.resource( + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "row_version_column_name": "row_hash", # the column "row_hash" should be provided by the resource + } +) +def dim_customer(): + ... +... +``` + +#### 🧪 Use scd2 with Arrow Tables and Panda frames +`dlt` will not add **row hash** column to the tabular data automatically (we are working on it). +You need to do that yourself by adding a transform function to `scd2` resource that computes row hashes (using pandas.util, should be fairly fast). +```py +import dlt +from dlt.sources.helpers.transform import add_row_hash_to_table + +scd2_r = dlt.resource( + arrow_table, + name="tabular", + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "row_version_column_name": "row_hash", + }, + ).add_map(add_row_hash_to_table("row_hash")) +``` +`add_row_hash_to_table` is the name of the transform function that will compute and create `row_hash` column that is declared as holding the hash by `row_version_column_name`. + +:::tip +You can modify existing resources that yield data in tabular form by calling `apply_hints` and passing `scd2` config in `write_disposition` and then by +adding the transform with `add_map`. +::: + +#### Child tables +Child tables, if any, do not contain validity columns. Validity columns are only added to the root table. Validity column values for records in child tables can be obtained by joining the root table using `_dlt_root_id`. + +#### Limitations + +* You cannot use columns like `updated_at` or integer `version` of a record that are unique within a `primary_key` (even if it is defined). Hash column +must be unique for a root table. We are working to allow `updated_at` style tracking +* We do not detect changes in child tables (except new records) if row hash of the corresponding parent row does not change. Use `updated_at` or similar +column in the root table to stamp changes in nested data. +* `merge_key(s)` are (for now) ignored. + ## Incremental loading with a cursor field In most of the REST APIs (and other data sources i.e. database tables) you can request new or updated diff --git a/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-gcp-cloud-function-as-webhook.md b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-gcp-cloud-function-as-webhook.md index 29a0ae86f8..cae8a7414d 100644 --- a/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-gcp-cloud-function-as-webhook.md +++ b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-gcp-cloud-function-as-webhook.md @@ -1,20 +1,20 @@ # Deploy GCP Cloud Function as a Webhook -A webhook is a way for one application to send automated messages or data to another application in real time. Unlike traditional APIs, which require constant polling for updates, webhooks allow applications to push information instantly as soon as an event occurs. This event-driven architecture enables faster and more responsive interactions between systems, saving valuable resources and improving overall system performance. +A webhook is a way for one application to send automated messages or data to another application in real time. Unlike traditional APIs, which require constant polling for updates, webhooks allow applications to push information instantly as soon as an event occurs. This event-driven architecture enables faster and more responsive interactions between systems, saving valuable resources and improving overall system performance. With this `dlt` google cloud event ingestion webhook, you can ingest the data and load it to the destination in real time as soon as a post request is triggered by the webhook. You can use this cloud function as an event ingestion webhook on various platforms such as Slack, Discord, Stripe, PayPal and any other as per your requirement. -You can setup GCP cloud function webhook using `dlt` as follows: +You can setup GCP cloud function webhook using `dlt` as follows: ## 1. **Initialize deployment** 1. Sign in to your GCP account and enable the Cloud Functions API. 2. Go to the Cloud Functions section and click Create Function. Set up the environment and select the region. -3. Configure the trigger type, you can use any trigger but for this example we will use HTTP and select "Allow unauthenticated invocations". +3. Configure the trigger type, you can use any trigger but for this example we will use HTTP and select "Allow unauthenticated invocations". 4. Click "Save" and then "Next". 5. Select "Python 3.10" as the environment. 6. Use the code provided to set up the cloud function for event ingestion: - + ```py import dlt import time @@ -24,31 +24,31 @@ You can setup GCP cloud function webhook using `dlt` as follows: def your_webhook(request): # Extract relevant data from the request payload data = request.get_json() - + Event = [data] - + pipeline = dlt.pipeline( pipeline_name='platform_to_bigquery', destination='bigquery', dataset_name='webhooks', ) - + pipeline.run(Event, table_name='webhook') #table_name can be customized return 'Event received and processed successfully.' ``` - + 7. Set the function name as "your_webhook" in the Entry point field. 8. In the requirements.txt file, specify the necessary packages: - + ```text # Function dependencies, for example: # package>=version dlt dlt[bigquery] ``` - + 9. Click on "Deploy" to complete the setup. - + > You can now use this cloud function as a webhook for event ingestion on various platforms such as Slack, Discord, Stripe, PayPal, and any other as per your requirement. Just remember to use the “Trigger URL” created by the cloud function when setting up the webhook. The Trigger URL can be found in the Trigger tab. @@ -58,7 +58,7 @@ To manually test the function you have created, you can send a manual POST reque ```sh import requests - + webhook_url = 'please set me up!' # Your cloud function Trigger URL message = { 'text': 'Hello, Slack!', @@ -72,9 +72,6 @@ if response.status_code == 200: else: print('Failed to send message. Error:', response.text) ``` - -> Replace the webhook_url with the Trigger URL for the cloud function created. -Now after setting up the webhook using cloud functions, every time an event occurs, the data will be ingested into your specified destination. - -That’s it! Enjoy deploying `DLT` GCP cloud function as webhook! +> Replace the webhook_url with the Trigger URL for the cloud function created. +Now after setting up the webhook using cloud functions, every time an event occurs, the data will be ingested into your specified destination. diff --git a/tests/cases.py b/tests/cases.py index f92c3ac5de..83814845a7 100644 --- a/tests/cases.py +++ b/tests/cases.py @@ -19,7 +19,7 @@ ) from dlt.common.schema import TColumnSchema, TTableSchemaColumns -from tests.utils import TArrowFormat, TestDataItemFormat, arrow_item_from_pandas +from tests.utils import TPythonTableFormat, TestDataItemFormat, arrow_item_from_pandas # _UUID = "c8209ee7-ee95-4b90-8c9f-f7a0f8b51014" JSON_TYPED_DICT: StrAny = { diff --git a/tests/common/storages/test_load_package.py b/tests/common/storages/test_load_package.py index 68396a76c8..ecbc5d296d 100644 --- a/tests/common/storages/test_load_package.py +++ b/tests/common/storages/test_load_package.py @@ -16,6 +16,7 @@ from dlt.common.configuration.container import Container from dlt.common.storages.load_package import ( LoadPackageStateInjectableContext, + create_load_id, destination_state, load_package, commit_load_package_state, @@ -69,7 +70,35 @@ def test_save_load_schema(load_storage: LoadStorage) -> None: assert schema.stored_version == schema_copy.stored_version -def test_create_and_update_loadpackage_state(load_storage: LoadStorage) -> None: +def test_create_package(load_storage: LoadStorage) -> None: + package_storage = load_storage.new_packages + # create package without initial state + load_id = create_load_id() + package_storage.create_package(load_id) + # get state, created at must be == load_id + state = package_storage.get_load_package_state(load_id) + assert state["created_at"] == pendulum.from_timestamp(float(load_id)) + # assume those few lines execute in less than a second + assert pendulum.now().diff(state["created_at"]).total_seconds() < 1 + + # create package with non timestamp load id + load_id = uniq_id() + package_storage.create_package(load_id) + state = package_storage.get_load_package_state(load_id) + # still valid created at is there + # assume those few lines execute in less than a second + assert pendulum.now().diff(state["created_at"]).total_seconds() < 1 + + force_created_at = pendulum.now().subtract(days=1) + state["destination_state"] = {"destination": "custom"} + state["created_at"] = force_created_at + load_id = uniq_id() + package_storage.create_package(load_id, initial_state=state) + state_2 = package_storage.get_load_package_state(load_id) + assert state_2["created_at"] == force_created_at + + +def test_create_and_update_load_package_state(load_storage: LoadStorage) -> None: load_storage.new_packages.create_package("copy") state = load_storage.new_packages.get_load_package_state("copy") assert state["_state_version"] == 0 @@ -88,12 +117,20 @@ def test_create_and_update_loadpackage_state(load_storage: LoadStorage) -> None: assert state["created_at"] == old_state["created_at"] # check timestamp - time = pendulum.parse(state["created_at"]) + created_at = state["created_at"] now = pendulum.now() - assert (now - time).in_seconds() < 2 # type: ignore + assert (now - created_at).in_seconds() < 2 -def test_loadpackage_state_injectable_context(load_storage: LoadStorage) -> None: +def test_create_load_id() -> None: + # must increase over time + load_id_1 = create_load_id() + sleep(0.1) + load_id_2 = create_load_id() + assert load_id_2 > load_id_1 + + +def test_load_package_state_injectable_context(load_storage: LoadStorage) -> None: load_storage.new_packages.create_package("copy") container = Container() diff --git a/tests/common/storages/test_load_storage.py b/tests/common/storages/test_load_storage.py index a70242001d..e8686ac2f9 100644 --- a/tests/common/storages/test_load_storage.py +++ b/tests/common/storages/test_load_storage.py @@ -6,6 +6,8 @@ from dlt.common.storages import PackageStorage, LoadStorage from dlt.common.storages.exceptions import LoadPackageNotFound, NoMigrationPathException +from dlt.common.storages.file_storage import FileStorage +from dlt.common.storages.load_package import create_load_id from tests.common.storages.utils import start_loading_file, assert_package_info, load_storage from tests.utils import write_version, autouse_test_storage @@ -158,6 +160,25 @@ def test_get_unknown_package_info(load_storage: LoadStorage) -> None: load_storage.get_load_package_info("UNKNOWN LOAD ID") +def test_import_extracted_package(load_storage: LoadStorage) -> None: + # create extracted package + extracted = PackageStorage( + FileStorage(os.path.join(load_storage.config.load_volume_path, "extracted")), "new" + ) + load_id = create_load_id() + extracted.create_package(load_id) + extracted_state = extracted.get_load_package_state(load_id) + load_storage.import_extracted_package(load_id, extracted) + # make sure state was imported + assert extracted_state == load_storage.new_packages.get_load_package_state(load_id) + # move to normalized + load_storage.commit_new_load_package(load_id) + assert extracted_state == load_storage.normalized_packages.get_load_package_state(load_id) + # move to loaded + load_storage.complete_load_package(load_id, aborted=False) + assert extracted_state == load_storage.loaded_packages.get_load_package_state(load_id) + + def test_full_migration_path() -> None: # create directory structure s = LoadStorage(True, LoadStorage.ALL_SUPPORTED_FILE_FORMATS) diff --git a/tests/extract/test_sources.py b/tests/extract/test_sources.py index 6ff1a0bf5f..5dd3d6c3ca 100644 --- a/tests/extract/test_sources.py +++ b/tests/extract/test_sources.py @@ -1317,6 +1317,28 @@ def empty_gen(): "primary_key": True, "merge_key": True, } + # test SCD2 write disposition hint + empty_r.apply_hints( + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "validity_column_names": ["from", "to"], + } + ) + assert empty_r._hints["write_disposition"] == { + "disposition": "merge", + "strategy": "scd2", + "validity_column_names": ["from", "to"], + } + assert "from" not in empty_r._hints["columns"] + assert "to" not in empty_r._hints["columns"] + table = empty_r.compute_table_schema() + assert table["write_disposition"] == "merge" + assert table["x-merge-strategy"] == "scd2" + assert "from" in table["columns"] + assert "x-valid-from" in table["columns"]["from"] + assert "to" in table["columns"] + assert "x-valid-to" in table["columns"]["to"] def test_apply_dynamic_hints() -> None: diff --git a/tests/load/pipeline/test_arrow_loading.py b/tests/load/pipeline/test_arrow_loading.py index 82ccb24bf1..b239899bce 100644 --- a/tests/load/pipeline/test_arrow_loading.py +++ b/tests/load/pipeline/test_arrow_loading.py @@ -14,7 +14,12 @@ from tests.load.utils import destinations_configs, DestinationTestConfiguration from tests.load.pipeline.utils import select_data from tests.pipeline.utils import assert_load_info -from tests.utils import TestDataItemFormat, arrow_item_from_pandas, preserve_environ, TArrowFormat +from tests.utils import ( + TestDataItemFormat, + arrow_item_from_pandas, + preserve_environ, + TPythonTableFormat, +) from tests.cases import arrow_table_all_data_types # mark all tests as essential, do not remove @@ -148,7 +153,7 @@ def some_data(): ) @pytest.mark.parametrize("item_type", ["arrow-table", "pandas", "arrow-batch"]) def test_parquet_column_names_are_normalized( - item_type: TArrowFormat, destination_config: DestinationTestConfiguration + item_type: TPythonTableFormat, destination_config: DestinationTestConfiguration ) -> None: """Test normalizing of parquet columns in all destinations""" # Create df with column names with inconsistent naming conventions diff --git a/tests/load/pipeline/test_merge_disposition.py b/tests/load/pipeline/test_merge_disposition.py index a9a82e39f7..bfcdccfba4 100644 --- a/tests/load/pipeline/test_merge_disposition.py +++ b/tests/load/pipeline/test_merge_disposition.py @@ -11,6 +11,7 @@ from dlt.common.configuration.container import Container from dlt.common.pipeline import StateInjectableContext from dlt.common.schema.utils import has_table_seen_data +from dlt.common.schema.exceptions import SchemaException from dlt.common.typing import StrAny from dlt.common.utils import digest128 from dlt.extract import DltResource @@ -946,3 +947,19 @@ def r(): ) with pytest.raises(PipelineStepFailed): info = p.run(r(), loader_file_format=destination_config.file_format) + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, subset=["duckdb"]), + ids=lambda x: x.name, +) +def test_invalid_merge_strategy(destination_config: DestinationTestConfiguration) -> None: + @dlt.resource(write_disposition={"disposition": "merge", "strategy": "foo"}) # type: ignore[call-overload] + def r(): + yield {"foo": "bar"} + + p = destination_config.setup_pipeline("abstract", full_refresh=True) + with pytest.raises(PipelineStepFailed) as pip_ex: + p.run(r()) + assert isinstance(pip_ex.value.__context__, SchemaException) diff --git a/tests/load/pipeline/test_scd2.py b/tests/load/pipeline/test_scd2.py new file mode 100644 index 0000000000..cf313eaa61 --- /dev/null +++ b/tests/load/pipeline/test_scd2.py @@ -0,0 +1,583 @@ +# timezone is removed from all datetime objects in these tests to simplify comparison + +import pytest +from typing import List, Dict, Any +from datetime import datetime, timezone # noqa: I251 + +import dlt +from dlt.common.pipeline import LoadInfo +from dlt.common.schema.exceptions import ColumnNameConflictException +from dlt.common.schema.typing import DEFAULT_VALIDITY_COLUMN_NAMES +from dlt.common.normalizers.json.relational import DataItemNormalizer +from dlt.common.normalizers.naming.snake_case import NamingConvention as SnakeCaseNamingConvention +from dlt.common.time import ensure_pendulum_datetime, reduce_pendulum_datetime_precision +from dlt.common.typing import TDataItem +from dlt.destinations.sql_jobs import HIGH_TS +from dlt.extract.resource import DltResource +from dlt.pipeline.exceptions import PipelineStepFailed + +from tests.cases import arrow_table_all_data_types +from tests.pipeline.utils import assert_load_info, load_table_counts +from tests.load.pipeline.utils import ( + destinations_configs, + DestinationTestConfiguration, + load_tables_to_dicts, +) +from tests.utils import TPythonTableFormat + +get_row_hash = DataItemNormalizer.get_row_hash + + +def get_active_ts(pipeline: dlt.Pipeline) -> datetime: + caps = pipeline._get_destination_capabilities() + active_ts = HIGH_TS.in_timezone(tz="UTC").replace(tzinfo=None) + return reduce_pendulum_datetime_precision(active_ts, caps.timestamp_precision) + + +def get_load_package_created_at(pipeline: dlt.Pipeline, load_info: LoadInfo) -> datetime: + """Returns `created_at` property of load package state.""" + load_id = load_info.asdict()["loads_ids"][0] + created_at = ( + pipeline.get_load_package_state(load_id)["created_at"] + .in_timezone(tz="UTC") + .replace(tzinfo=None) + ) + caps = pipeline._get_destination_capabilities() + return reduce_pendulum_datetime_precision(created_at, caps.timestamp_precision) + + +def strip_timezone(ts: datetime) -> datetime: + """Converts timezone of datetime object to UTC and removes timezone awareness.""" + ts = ensure_pendulum_datetime(ts) + if ts.replace(tzinfo=None) == HIGH_TS: + return ts.replace(tzinfo=None) + else: + return ts.astimezone(tz=timezone.utc).replace(tzinfo=None) + + +def get_table( + pipeline: dlt.Pipeline, table_name: str, sort_column: str, include_root_id: bool = True +) -> List[Dict[str, Any]]: + """Returns destination table contents as list of dictionaries.""" + return sorted( + [ + { + k: strip_timezone(v) if isinstance(v, datetime) else v + for k, v in r.items() + if not k.startswith("_dlt") + or k in DEFAULT_VALIDITY_COLUMN_NAMES + or (k == "_dlt_root_id" if include_root_id else False) + } + for r in load_tables_to_dicts(pipeline, table_name)[table_name] + ], + key=lambda d: d[sort_column], + ) + + +def assert_records_as_set(actual: List[Dict[str, Any]], expected: List[Dict[str, Any]]) -> None: + """Compares two lists of dicts regardless of order""" + actual_set = set(frozenset(dict_.items()) for dict_ in actual) + expected_set = set(frozenset(dict_.items()) for dict_ in expected) + assert actual_set == expected_set + + +@pytest.mark.parametrize( + "destination_config,simple,validity_column_names", + [ # test basic case for alle SQL destinations supporting merge + (dconf, True, None) + for dconf in destinations_configs(default_sql_configs=True, supports_merge=True) + ] + + [ # test nested columns and validity column name configuration only for postgres + ( + dconf, + False, + ["from", "to"], + ) # "from" is a SQL keyword, so this also tests if columns are escaped + for dconf in destinations_configs(default_sql_configs=True, subset=["postgres", "duckdb"]) + ] + + [ + (dconf, False, ["ValidFrom", "ValidTo"]) + for dconf in destinations_configs(default_sql_configs=True, subset=["postgres", "duckdb"]) + ], + ids=lambda x: ( + x.name + if isinstance(x, DestinationTestConfiguration) + else (x[0] + "-" + x[1] if isinstance(x, list) else x) + ), +) +def test_core_functionality( + destination_config: DestinationTestConfiguration, + simple: bool, + validity_column_names: List[str], +) -> None: + p = destination_config.setup_pipeline("abstract", full_refresh=True) + + @dlt.resource( + table_name="dim_test", + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "validity_column_names": validity_column_names, + }, + ) + def r(data): + yield data + + # get validity column names + from_, to = ( + DEFAULT_VALIDITY_COLUMN_NAMES + if validity_column_names is None + else map(SnakeCaseNamingConvention().normalize_identifier, validity_column_names) + ) + + # load 1 — initial load + dim_snap = [ + {"nk": 1, "c1": "foo", "c2": "foo" if simple else {"nc1": "foo"}}, + {"nk": 2, "c1": "bar", "c2": "bar" if simple else {"nc1": "bar"}}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + # assert x-hints + table = p.default_schema.get_table("dim_test") + assert table["x-merge-strategy"] == "scd2" # type: ignore[typeddict-item] + assert table["columns"][from_]["x-valid-from"] # type: ignore[typeddict-item] + assert table["columns"][to]["x-valid-to"] # type: ignore[typeddict-item] + assert table["columns"]["_dlt_id"]["x-row-version"] # type: ignore[typeddict-item] + # _dlt_id is still unique + assert table["columns"]["_dlt_id"]["unique"] + + # assert load results + ts_1 = get_load_package_created_at(p, info) + assert_load_info(info) + cname = "c2" if simple else "c2__nc1" + assert get_table(p, "dim_test", cname) == [ + {from_: ts_1, to: get_active_ts(p), "nk": 2, "c1": "bar", cname: "bar"}, + {from_: ts_1, to: get_active_ts(p), "nk": 1, "c1": "foo", cname: "foo"}, + ] + + # load 2 — update a record + dim_snap = [ + {"nk": 1, "c1": "foo", "c2": "foo_updated" if simple else {"nc1": "foo_updated"}}, + {"nk": 2, "c1": "bar", "c2": "bar" if simple else {"nc1": "bar"}}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_2 = get_load_package_created_at(p, info) + assert_load_info(info) + assert get_table(p, "dim_test", cname) == [ + {from_: ts_1, to: get_active_ts(p), "nk": 2, "c1": "bar", cname: "bar"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo", cname: "foo"}, + {from_: ts_2, to: get_active_ts(p), "nk": 1, "c1": "foo", cname: "foo_updated"}, + ] + + # load 3 — delete a record + dim_snap = [ + {"nk": 1, "c1": "foo", "c2": "foo_updated" if simple else {"nc1": "foo_updated"}}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_3 = get_load_package_created_at(p, info) + assert_load_info(info) + assert get_table(p, "dim_test", cname) == [ + {from_: ts_1, to: ts_3, "nk": 2, "c1": "bar", cname: "bar"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo", cname: "foo"}, + {from_: ts_2, to: get_active_ts(p), "nk": 1, "c1": "foo", cname: "foo_updated"}, + ] + + # load 4 — insert a record + dim_snap = [ + {"nk": 1, "c1": "foo", "c2": "foo_updated" if simple else {"nc1": "foo_updated"}}, + {"nk": 3, "c1": "baz", "c2": "baz" if simple else {"nc1": "baz"}}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_4 = get_load_package_created_at(p, info) + assert_load_info(info) + assert get_table(p, "dim_test", cname) == [ + {from_: ts_1, to: ts_3, "nk": 2, "c1": "bar", cname: "bar"}, + {from_: ts_4, to: get_active_ts(p), "nk": 3, "c1": "baz", cname: "baz"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo", cname: "foo"}, + {from_: ts_2, to: get_active_ts(p), "nk": 1, "c1": "foo", cname: "foo_updated"}, + ] + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, supports_merge=True), + ids=lambda x: x.name, +) +@pytest.mark.parametrize("simple", [True, False]) +def test_child_table(destination_config: DestinationTestConfiguration, simple: bool) -> None: + p = destination_config.setup_pipeline("abstract", full_refresh=True) + + @dlt.resource( + table_name="dim_test", write_disposition={"disposition": "merge", "strategy": "scd2"} + ) + def r(data): + yield data + + # get validity column names + from_, to = DEFAULT_VALIDITY_COLUMN_NAMES + + # load 1 — initial load + dim_snap: List[Dict[str, Any]] = [ + l1_1 := {"nk": 1, "c1": "foo", "c2": [1] if simple else [{"cc1": 1}]}, + l1_2 := {"nk": 2, "c1": "bar", "c2": [2, 3] if simple else [{"cc1": 2}, {"cc1": 3}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_1 = get_load_package_created_at(p, info) + assert_load_info(info) + assert get_table(p, "dim_test", "c1") == [ + {from_: ts_1, to: get_active_ts(p), "nk": 2, "c1": "bar"}, + {from_: ts_1, to: get_active_ts(p), "nk": 1, "c1": "foo"}, + ] + cname = "value" if simple else "cc1" + assert get_table(p, "dim_test__c2", cname) == [ + {"_dlt_root_id": get_row_hash(l1_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l1_2), cname: 2}, + {"_dlt_root_id": get_row_hash(l1_2), cname: 3}, + ] + + # load 2 — update a record — change not in complex column + dim_snap = [ + l2_1 := {"nk": 1, "c1": "foo_updated", "c2": [1] if simple else [{"cc1": 1}]}, + {"nk": 2, "c1": "bar", "c2": [2, 3] if simple else [{"cc1": 2}, {"cc1": 3}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_2 = get_load_package_created_at(p, info) + assert_load_info(info) + assert get_table(p, "dim_test", "c1") == [ + {from_: ts_1, to: get_active_ts(p), "nk": 2, "c1": "bar"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo"}, # updated + {from_: ts_2, to: get_active_ts(p), "nk": 1, "c1": "foo_updated"}, # new + ] + assert_records_as_set( + get_table(p, "dim_test__c2", cname), + [ + {"_dlt_root_id": get_row_hash(l1_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l2_1), cname: 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), cname: 2}, + {"_dlt_root_id": get_row_hash(l1_2), cname: 3}, + ], + ) + + # load 3 — update a record — change in complex column + dim_snap = [ + l3_1 := { + "nk": 1, + "c1": "foo_updated", + "c2": [1, 2] if simple else [{"cc1": 1}, {"cc1": 2}], + }, + {"nk": 2, "c1": "bar", "c2": [2, 3] if simple else [{"cc1": 2}, {"cc1": 3}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_3 = get_load_package_created_at(p, info) + assert_load_info(info) + assert_records_as_set( + get_table(p, "dim_test", "c1"), + [ + {from_: ts_1, to: get_active_ts(p), "nk": 2, "c1": "bar"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo"}, + {from_: ts_2, to: ts_3, "nk": 1, "c1": "foo_updated"}, # updated + {from_: ts_3, to: get_active_ts(p), "nk": 1, "c1": "foo_updated"}, # new + ], + ) + exp_3 = [ + {"_dlt_root_id": get_row_hash(l1_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l2_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l3_1), cname: 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), cname: 2}, + {"_dlt_root_id": get_row_hash(l3_1), cname: 2}, # new + {"_dlt_root_id": get_row_hash(l1_2), cname: 3}, + ] + assert_records_as_set(get_table(p, "dim_test__c2", cname), exp_3) + + # load 4 — delete a record + dim_snap = [ + {"nk": 1, "c1": "foo_updated", "c2": [1, 2] if simple else [{"cc1": 1}, {"cc1": 2}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_4 = get_load_package_created_at(p, info) + assert_load_info(info) + assert_records_as_set( + get_table(p, "dim_test", "c1"), + [ + {from_: ts_1, to: ts_4, "nk": 2, "c1": "bar"}, # updated + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo"}, + {from_: ts_2, to: ts_3, "nk": 1, "c1": "foo_updated"}, + {from_: ts_3, to: get_active_ts(p), "nk": 1, "c1": "foo_updated"}, + ], + ) + assert_records_as_set( + get_table(p, "dim_test__c2", cname), exp_3 + ) # deletes should not alter child tables + + # load 5 — insert a record + dim_snap = [ + {"nk": 1, "c1": "foo_updated", "c2": [1, 2] if simple else [{"cc1": 1}, {"cc1": 2}]}, + l5_3 := {"nk": 3, "c1": "baz", "c2": [1, 2] if simple else [{"cc1": 1}, {"cc1": 2}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + ts_5 = get_load_package_created_at(p, info) + assert_load_info(info) + assert_records_as_set( + get_table(p, "dim_test", "c1"), + [ + {from_: ts_1, to: ts_4, "nk": 2, "c1": "bar"}, + {from_: ts_5, to: get_active_ts(p), "nk": 3, "c1": "baz"}, # new + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo"}, + {from_: ts_2, to: ts_3, "nk": 1, "c1": "foo_updated"}, + {from_: ts_3, to: get_active_ts(p), "nk": 1, "c1": "foo_updated"}, + ], + ) + assert_records_as_set( + get_table(p, "dim_test__c2", cname), + [ + {"_dlt_root_id": get_row_hash(l1_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l2_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l3_1), cname: 1}, + {"_dlt_root_id": get_row_hash(l5_3), cname: 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), cname: 2}, + {"_dlt_root_id": get_row_hash(l3_1), cname: 2}, + {"_dlt_root_id": get_row_hash(l5_3), cname: 2}, # new + {"_dlt_root_id": get_row_hash(l1_2), cname: 3}, + ], + ) + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, supports_merge=True), + ids=lambda x: x.name, +) +def test_grandchild_table(destination_config: DestinationTestConfiguration) -> None: + p = destination_config.setup_pipeline("abstract", full_refresh=True) + + @dlt.resource( + table_name="dim_test", write_disposition={"disposition": "merge", "strategy": "scd2"} + ) + def r(data): + yield data + + # load 1 — initial load + dim_snap = [ + l1_1 := {"nk": 1, "c1": "foo", "c2": [{"cc1": [1]}]}, + l1_2 := {"nk": 2, "c1": "bar", "c2": [{"cc1": [1, 2]}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + assert_records_as_set( + get_table(p, "dim_test__c2__cc1", "value"), + [ + {"_dlt_root_id": get_row_hash(l1_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l1_2), "value": 1}, + {"_dlt_root_id": get_row_hash(l1_2), "value": 2}, + ], + ) + + # load 2 — update a record — change not in complex column + dim_snap = [ + l2_1 := {"nk": 1, "c1": "foo_updated", "c2": [{"cc1": [1]}]}, + l1_2 := {"nk": 2, "c1": "bar", "c2": [{"cc1": [1, 2]}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + assert_records_as_set( + (get_table(p, "dim_test__c2__cc1", "value")), + [ + {"_dlt_root_id": get_row_hash(l1_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l1_2), "value": 1}, + {"_dlt_root_id": get_row_hash(l2_1), "value": 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), "value": 2}, + ], + ) + + # load 3 — update a record — change in complex column + dim_snap = [ + l3_1 := {"nk": 1, "c1": "foo_updated", "c2": [{"cc1": [1, 2]}]}, + {"nk": 2, "c1": "bar", "c2": [{"cc1": [1, 2]}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + exp_3 = [ + {"_dlt_root_id": get_row_hash(l1_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l1_2), "value": 1}, + {"_dlt_root_id": get_row_hash(l2_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l3_1), "value": 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), "value": 2}, + {"_dlt_root_id": get_row_hash(l3_1), "value": 2}, # new + ] + assert_records_as_set(get_table(p, "dim_test__c2__cc1", "value"), exp_3) + + # load 4 — delete a record + dim_snap = [ + {"nk": 1, "c1": "foo_updated", "c2": [{"cc1": [1, 2]}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + assert_records_as_set(get_table(p, "dim_test__c2__cc1", "value"), exp_3) + + # load 5 — insert a record + dim_snap = [ + {"nk": 1, "c1": "foo_updated", "c2": [{"cc1": [1, 2]}]}, + l5_3 := {"nk": 3, "c1": "baz", "c2": [{"cc1": [1]}]}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + assert_records_as_set( + get_table(p, "dim_test__c2__cc1", "value"), + [ + {"_dlt_root_id": get_row_hash(l1_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l1_2), "value": 1}, + {"_dlt_root_id": get_row_hash(l2_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l3_1), "value": 1}, + {"_dlt_root_id": get_row_hash(l5_3), "value": 1}, # new + {"_dlt_root_id": get_row_hash(l1_2), "value": 2}, + {"_dlt_root_id": get_row_hash(l3_1), "value": 2}, + ], + ) + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, subset=["duckdb"]), + ids=lambda x: x.name, +) +def test_validity_column_name_conflict(destination_config: DestinationTestConfiguration) -> None: + p = destination_config.setup_pipeline("abstract", full_refresh=True) + + @dlt.resource( + table_name="dim_test", + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "validity_column_names": ["from", "to"], + }, + ) + def r(data): + yield data + + # configuring a validity column name that appears in the data should cause an exception + dim_snap = {"nk": 1, "foo": 1, "from": 1} # conflict on "from" column + with pytest.raises(PipelineStepFailed) as pip_ex: + p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert isinstance(pip_ex.value.__context__.__context__, ColumnNameConflictException) + dim_snap = {"nk": 1, "foo": 1, "to": 1} # conflict on "to" column + with pytest.raises(PipelineStepFailed): + p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert isinstance(pip_ex.value.__context__.__context__, ColumnNameConflictException) + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, subset=["duckdb"]), + ids=lambda x: x.name, +) +@pytest.mark.parametrize("item_type", ["pandas", "arrow-table", "arrow-batch"]) +def test_arrow_custom_hash( + destination_config: DestinationTestConfiguration, item_type: TPythonTableFormat +) -> None: + table, _, _ = arrow_table_all_data_types(item_type, num_rows=100, include_json=False) + orig_table: Any = None + if item_type == "pandas": + orig_table = table.copy(deep=True) + + from dlt.sources.helpers.transform import add_row_hash_to_table + + def _make_scd2_r(table_: Any) -> DltResource: + return dlt.resource( + table_, + name="tabular", + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "row_version_column_name": "row_hash", + }, + ).add_map(add_row_hash_to_table("row_hash")) + + p = destination_config.setup_pipeline("abstract", full_refresh=True) + info = p.run(_make_scd2_r(table), loader_file_format=destination_config.file_format) + assert_load_info(info) + # make sure we have scd2 columns in schema + table_schema = p.default_schema.get_table("tabular") + assert table_schema["x-merge-strategy"] == "scd2" # type: ignore[typeddict-item] + from_, to = DEFAULT_VALIDITY_COLUMN_NAMES + assert table_schema["columns"][from_]["x-valid-from"] # type: ignore[typeddict-item] + assert table_schema["columns"][to]["x-valid-to"] # type: ignore[typeddict-item] + assert table_schema["columns"]["row_hash"]["x-row-version"] # type: ignore[typeddict-item] + # 100 items in destination + assert load_table_counts(p, "tabular")["tabular"] == 100 + + # modify in place (pandas only) + if item_type == "pandas": + table = orig_table + orig_table = table.copy(deep=True) + info = p.run(_make_scd2_r(table), loader_file_format=destination_config.file_format) + assert_load_info(info) + # no changes (hopefully hash is deterministic) + assert load_table_counts(p, "tabular")["tabular"] == 100 + + # change single row + orig_table.iloc[0, 0] = "Duck 🦆!" + info = p.run(_make_scd2_r(orig_table), loader_file_format=destination_config.file_format) + assert_load_info(info) + # on row changed + assert load_table_counts(p, "tabular")["tabular"] == 101 + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(default_sql_configs=True, subset=["duckdb"]), + ids=lambda x: x.name, +) +def test_user_provided_row_hash(destination_config: DestinationTestConfiguration) -> None: + p = destination_config.setup_pipeline("abstract", full_refresh=True) + + @dlt.resource( + table_name="dim_test", + write_disposition={ + "disposition": "merge", + "strategy": "scd2", + "row_version_column_name": "row_hash", + }, + ) + def r(data): + yield data + + # load 1 — initial load + dim_snap: List[Dict[str, Any]] = [ + {"nk": 1, "c1": "foo", "c2": [1], "row_hash": "mocked_hash_1"}, + {"nk": 2, "c1": "bar", "c2": [2, 3], "row_hash": "mocked_hash_2"}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + ts_1 = get_load_package_created_at(p, info) + table = p.default_schema.get_table("dim_test") + assert table["columns"]["row_hash"]["x-row-version"] # type: ignore[typeddict-item] + assert "x-row-version" not in table["columns"]["_dlt_id"] + + # load 2 — update and delete a record + dim_snap = [ + {"nk": 1, "c1": "foo_upd", "c2": [1], "row_hash": "mocked_hash_1_upd"}, + ] + info = p.run(r(dim_snap), loader_file_format=destination_config.file_format) + assert_load_info(info) + ts_2 = get_load_package_created_at(p, info) + + # assert load results + from_, to = DEFAULT_VALIDITY_COLUMN_NAMES + assert get_table(p, "dim_test", "c1") == [ + {from_: ts_1, to: ts_2, "nk": 2, "c1": "bar", "row_hash": "mocked_hash_2"}, + {from_: ts_1, to: ts_2, "nk": 1, "c1": "foo", "row_hash": "mocked_hash_1"}, + { + from_: ts_2, + to: get_active_ts(p), + "nk": 1, + "c1": "foo_upd", + "row_hash": "mocked_hash_1_upd", + }, + ] + # root id is not deterministic when a user provided row hash is used + assert get_table(p, "dim_test__c2", "value", include_root_id=False) == [ + {"value": 1}, + {"value": 1}, + {"value": 2}, + {"value": 3}, + ] diff --git a/tests/pipeline/test_arrow_sources.py b/tests/pipeline/test_arrow_sources.py index d9930c19ee..667f26476b 100644 --- a/tests/pipeline/test_arrow_sources.py +++ b/tests/pipeline/test_arrow_sources.py @@ -19,7 +19,7 @@ ) from tests.utils import ( preserve_environ, - TArrowFormat, + TPythonTableFormat, arrow_item_from_pandas, arrow_item_from_table, ) @@ -36,7 +36,7 @@ ("arrow-batch", True), ], ) -def test_extract_and_normalize(item_type: TArrowFormat, is_list: bool): +def test_extract_and_normalize(item_type: TPythonTableFormat, is_list: bool): item, records, data = arrow_table_all_data_types(item_type) pipeline = dlt.pipeline("arrow_" + uniq_id(), destination="filesystem") @@ -121,7 +121,7 @@ def some_data(): ("arrow-batch", True), ], ) -def test_normalize_jsonl(item_type: TArrowFormat, is_list: bool): +def test_normalize_jsonl(item_type: TPythonTableFormat, is_list: bool): os.environ["DUMMY__LOADER_FILE_FORMAT"] = "jsonl" item, records, _ = arrow_table_all_data_types(item_type, tz="Europe/Berlin") @@ -154,7 +154,7 @@ def some_data(): @pytest.mark.parametrize("item_type", ["arrow-table", "arrow-batch"]) -def test_add_map(item_type: TArrowFormat): +def test_add_map(item_type: TPythonTableFormat): item, _, _ = arrow_table_all_data_types(item_type, num_rows=200) @dlt.resource @@ -176,7 +176,7 @@ def map_func(item): @pytest.mark.parametrize("item_type", ["pandas", "arrow-table", "arrow-batch"]) -def test_extract_normalize_file_rotation(item_type: TArrowFormat) -> None: +def test_extract_normalize_file_rotation(item_type: TPythonTableFormat) -> None: # do not extract state os.environ["RESTORE_FROM_DESTINATION"] = "False" # use parquet for dummy @@ -208,7 +208,7 @@ def data_frames(): @pytest.mark.parametrize("item_type", ["pandas", "arrow-table", "arrow-batch"]) -def test_arrow_clashing_names(item_type: TArrowFormat) -> None: +def test_arrow_clashing_names(item_type: TPythonTableFormat) -> None: # # use parquet for dummy os.environ["DESTINATION__LOADER_FILE_FORMAT"] = "parquet" pipeline_name = "arrow_" + uniq_id() @@ -227,7 +227,7 @@ def data_frames(): @pytest.mark.parametrize("item_type", ["arrow-table", "arrow-batch"]) -def test_load_arrow_vary_schema(item_type: TArrowFormat) -> None: +def test_load_arrow_vary_schema(item_type: TPythonTableFormat) -> None: pipeline_name = "arrow_" + uniq_id() pipeline = dlt.pipeline(pipeline_name=pipeline_name, destination="duckdb") @@ -246,7 +246,7 @@ def test_load_arrow_vary_schema(item_type: TArrowFormat) -> None: @pytest.mark.parametrize("item_type", ["pandas", "arrow-table", "arrow-batch"]) -def test_arrow_as_data_loading(item_type: TArrowFormat) -> None: +def test_arrow_as_data_loading(item_type: TPythonTableFormat) -> None: os.environ["RESTORE_FROM_DESTINATION"] = "False" os.environ["DESTINATION__LOADER_FILE_FORMAT"] = "parquet" @@ -264,7 +264,7 @@ def test_arrow_as_data_loading(item_type: TArrowFormat) -> None: @pytest.mark.parametrize("item_type", ["arrow-table"]) # , "pandas", "arrow-batch" -def test_normalize_with_dlt_columns(item_type: TArrowFormat): +def test_normalize_with_dlt_columns(item_type: TPythonTableFormat): item, records, _ = arrow_table_all_data_types(item_type, num_rows=5432) os.environ["NORMALIZE__PARQUET_NORMALIZER__ADD_DLT_LOAD_ID"] = "True" os.environ["NORMALIZE__PARQUET_NORMALIZER__ADD_DLT_ID"] = "True" @@ -330,7 +330,7 @@ def some_data(): @pytest.mark.parametrize("item_type", ["arrow-table", "pandas", "arrow-batch"]) -def test_normalize_reorder_columns_separate_packages(item_type: TArrowFormat) -> None: +def test_normalize_reorder_columns_separate_packages(item_type: TPythonTableFormat) -> None: os.environ["RESTORE_FROM_DESTINATION"] = "False" table, shuffled_table, shuffled_removed_column = prepare_shuffled_tables() @@ -381,7 +381,7 @@ def _to_item(table: Any) -> Any: @pytest.mark.parametrize("item_type", ["arrow-table", "pandas", "arrow-batch"]) -def test_normalize_reorder_columns_single_package(item_type: TArrowFormat) -> None: +def test_normalize_reorder_columns_single_package(item_type: TPythonTableFormat) -> None: os.environ["RESTORE_FROM_DESTINATION"] = "False" # we do not want to rotate buffer os.environ["DATA_WRITER__BUFFER_MAX_ITEMS"] = "100000" @@ -423,7 +423,7 @@ def _to_item(table: Any) -> Any: @pytest.mark.parametrize("item_type", ["arrow-table", "pandas", "arrow-batch"]) -def test_normalize_reorder_columns_single_batch(item_type: TArrowFormat) -> None: +def test_normalize_reorder_columns_single_batch(item_type: TPythonTableFormat) -> None: os.environ["RESTORE_FROM_DESTINATION"] = "False" # we do not want to rotate buffer os.environ["DATA_WRITER__BUFFER_MAX_ITEMS"] = "100000" @@ -475,7 +475,7 @@ def _to_item(table: Any) -> Any: @pytest.mark.parametrize("item_type", ["pandas", "arrow-table", "arrow-batch"]) -def test_empty_arrow(item_type: TArrowFormat) -> None: +def test_empty_arrow(item_type: TPythonTableFormat) -> None: os.environ["RESTORE_FROM_DESTINATION"] = "False" os.environ["DESTINATION__LOADER_FILE_FORMAT"] = "parquet" diff --git a/tests/pipeline/utils.py b/tests/pipeline/utils.py index 8f736e13d9..c4e1f5314b 100644 --- a/tests/pipeline/utils.py +++ b/tests/pipeline/utils.py @@ -171,6 +171,7 @@ def load_tables_to_dicts(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[D for table_name in table_names: table_rows = [] columns = p.default_schema.get_table_columns(table_name).keys() + query_columns = ",".join(map(p.sql_client().capabilities.escape_identifier, columns)) with p.sql_client() as c: query_columns = ",".join(map(c.escape_column_name, columns)) diff --git a/tests/utils.py b/tests/utils.py index 45aa29a416..1ccb7fc5e4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -77,7 +77,7 @@ for destination in ACTIVE_DESTINATIONS: assert destination in IMPLEMENTED_DESTINATIONS, f"Unknown active destination {destination}" -TArrowFormat = Literal["pandas", "arrow-table", "arrow-batch"] +TPythonTableFormat = Literal["pandas", "arrow-table", "arrow-batch"] """Possible arrow item formats""" TestDataItemFormat = Literal["object", "pandas", "arrow-table", "arrow-batch"] @@ -229,7 +229,7 @@ def data_item_length(data: TDataItem) -> int: def arrow_item_from_pandas( df: Any, - object_format: TArrowFormat, + object_format: TPythonTableFormat, ) -> Any: from dlt.common.libs.pyarrow import pyarrow as pa @@ -244,7 +244,7 @@ def arrow_item_from_pandas( def arrow_item_from_table( table: Any, - object_format: TArrowFormat, + object_format: TPythonTableFormat, ) -> Any: if object_format == "pandas": return table.to_pandas() diff --git a/tox.ini b/tox.ini index 9469001572..ed6c69c585 100644 --- a/tox.ini +++ b/tox.ini @@ -5,4 +5,5 @@ banned-modules = datetime = use dlt.common.pendulum decimal = use dlt.common.decimal decimal.Decimal = use dlt.common.Decimal open = use dlt.common.open + pendulum = use dlt.common.pendulum extend-immutable-calls = dlt.sources.incremental From f5a6dbdc27c679cc2f5830b4a2d955a5678ae8a4 Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:22:43 +0530 Subject: [PATCH 08/41] Corrected a snippet for naming = "direct" (#1215) --- docs/website/docs/general-usage/schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/website/docs/general-usage/schema.md b/docs/website/docs/general-usage/schema.md index 60ae5147f9..a66552cb7f 100644 --- a/docs/website/docs/general-usage/schema.md +++ b/docs/website/docs/general-usage/schema.md @@ -67,7 +67,7 @@ The default naming convention: To retain the original naming convention (like keeping `"createdAt"` as it is instead of converting it to `"created_at"`), you can use the direct naming convention, in "config.toml" as follows: ```toml [schema] -SCHEMA__NAMING = "direct" +naming="direct" ``` :::caution Opting for `"direct"` naming bypasses most name normalization processes. This means any unusual characters present will be carried over unchanged to database tables and columns. Please be aware of this behavior to avoid potential issues. From e04221edde54da49de54db835d10c95c6dd332f8 Mon Sep 17 00:00:00 2001 From: Zaeem Athar Date: Mon, 15 Apr 2024 16:05:15 +0200 Subject: [PATCH 09/41] Update synapse.md Setting hide password property to `True` --- docs/website/docs/dlt-ecosystem/destinations/synapse.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index d57ff4cab6..4d249e3d35 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -120,7 +120,7 @@ Once you have the connection URL, you can directly use it in your pipeline confi pipeline = dlt.pipeline( pipeline_name='chess', destination=dlt.destinations.synapse( - credentials=connection_url.render_as_string(hide_password=False) + credentials=connection_url.render_as_string(hide_password=True) ), dataset_name='chess_data' ) From 11b0c68a8683ca1b7ebd15c09fea935f418344d1 Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Mon, 15 Apr 2024 22:19:57 +0530 Subject: [PATCH 10/41] Added docs for deploying dlt with Prefect. (#1138) * Added docs for 'deploy dlt with Prefect'. * Updated doc * Update deploy-with-prefect.md --------- Co-authored-by: Zaeem Athar --- .../deploy-a-pipeline/deploy-with-prefect.md | 69 +++++++++++++++++++ docs/website/sidebars.js | 1 + 2 files changed, 70 insertions(+) create mode 100644 docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md diff --git a/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md new file mode 100644 index 0000000000..6fbf0f0d16 --- /dev/null +++ b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md @@ -0,0 +1,69 @@ +--- +title: Deploy with Prefect +description: How to deploy a pipeline with Prefect +keywords: [how to, deploy a pipeline, Prefect] +--- + +# Deploy with Prefect + +## Introduction to Prefect + +Prefect is a workflow management system that automates and orchestrates data pipelines. As an open-source platform, it offers a framework for defining, scheduling, and executing tasks with dependencies. It enables users to scale and maintain their data workflows efficiently. + +### Prefect features + +- **Flows**: These contain workflow logic, and are defined as Python functions. +- **Tasks**: A task represents a discrete unit of work. Tasks allow encapsulation of workflow logic that can be reused for flows and subflows. +- **Deployments and Scheduling**: Deployments transform workflows from manually called functions into API-managed entities that you can trigger remotely. Prefect allows you to use schedules to automatically create new flow runs for deployments. +- **Automation:** Prefect Cloud enables you to configure [actions](https://docs.prefect.io/latest/concepts/automations/#actions) that Prefect executes automatically based on [trigger](https://docs.prefect.io/latest/concepts/automations/#triggers) conditions. +- **Caching:** This feature enables a task to reflect a completed state without actually executing its defining code. +- **Oberservality**: This feature allows users to monitor workflows and tasks. It provides insights into data pipeline performance and behavior through logging, metrics, and notifications. + +## Building Data Pipelines with `dlt` + +`dlt` is an open-source Python library that enables the declarative loading of data sources into well-structured tables or datasets by automatically inferring and evolving schemas. It simplifies the construction of data pipelines by offering functionality to support the complete extract and load process. + +### How does **`dlt`** integrate with Prefect for pipeline orchestration? + +Here's a concise guide to orchestrating a `dlt` pipeline with Prefect using "Moving Slack data into BigQuery" as an example. You can find a comprehensive, step-by-step guide in the article [“Building resilient data pipelines in minutes with dlt + Prefect”,](https://www.prefect.io/blog/building-resilient-data-pipelines-in-minutes-with-dlt-prefect) and the corresponding GitHub repository [here.](https://github.com/dylanbhughes/dlt_slack_pipeline/blob/main/slack_pipeline_with_prefect.py) + +### Here’s a summary of the steps followed: + +1. Create a `dlt` pipeline. For detailed instructions on creating a pipeline, please refer to the [documentation](https://dlthub.com/docs/walkthroughs/create-a-pipeline). + +1. Add `@task` decorator to the individual functions. + 1. Here we use `@task` decorator for `get_users` function: + + ```py + @task + def get_users() -> None: + """Execute a pipeline that will load Slack users list.""" + ``` + + 1. Use `@flow` function on the `slack_pipeline` function as: + + ```py + @flow + def slack_pipeline( + channels=None, + start_date=pendulum.now().subtract(days=1).date() + ) -> None: + get_users() + + ``` + +2. Lastly, append `.serve` to the `if __name__ == '__main__'` block to automatically create and schedule a Prefect deployment for daily execution as: + + ```py + if __name__ == "__main__": + slack_pipeline.serve("slack_pipeline", cron="0 0 * * *") + ``` + +3. You can view deployment details and scheduled runs, including successes and failures, using [PrefectUI](https://app.prefect.cloud/auth/login). This will help you know when a pipeline ran or more importantly, when it did not. + + +You can further extend the pipeline further by: + +- Setting up [remote infrastructure with workers.](https://docs.prefect.io/latest/tutorial/workers/?deviceId=bb3e22c1-c2c7-4981-bd5e-c81715503e08) +- [Adding automations](https://docs.prefect.io/latest/concepts/automations/?deviceId=bb3e22c1-c2c7-4981-bd5e-c81715503e08), to notify the status of pipeline run. +- [Setting up retries](https://docs.prefect.io/latest/concepts/tasks/?deviceId=bb3e22c1-c2c7-4981-bd5e-c81715503e08#custom-retry-behavior). diff --git a/docs/website/sidebars.js b/docs/website/sidebars.js index 418ac2efd6..2f2c444286 100644 --- a/docs/website/sidebars.js +++ b/docs/website/sidebars.js @@ -235,6 +235,7 @@ const sidebars = { 'walkthroughs/deploy-a-pipeline/deploy-gcp-cloud-function-as-webhook', 'walkthroughs/deploy-a-pipeline/deploy-with-kestra', 'walkthroughs/deploy-a-pipeline/deploy-with-dagster', + 'walkthroughs/deploy-a-pipeline/deploy-with-prefect', ] }, { From 2ce906c606344402506f34aa0d206be01055e129 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:49:12 +0200 Subject: [PATCH 11/41] Extend layout placeholder params for filesystem destinations (#1182) * Introduce new config fields to filesystem destination configuration * Merge layout.py with path_utils.py * Adjust tests * Fix linting issues and extract common types * Use pendulum.now if current_datetime is not defined * Add more layout tests * Fix failing tests * Cleanup tests * Adjust tests * Enable auto_mkdir for local filesystem * Format code * Extract re-usable functions and fix test * Add invalid layout * Accept load_package timestamp in create_path * Collect all files and directories and then delete files first then directories * Recursively descend and collect files and directories to truncate for filesystem destination * Mock load_package timestamp and add more test layouts * Fix linting issues * Use better variable name to avoid shadowing builtin name * Fix dummy tests * Revert changes to filesystem impl * Use timestamp if it is specified * Cleanup path_utils and remove redundant code * Revert factory * Refactor path_utils and simplify things * Remove custom datetime format parameter * Pass load_package_timestamp * Remove custom datetime format and current_datetime parameter parameter * Cleanup imports * Fix path_utils tests * Make load_package_timestamp optional * Revert some changes in tests * Uncomment layout test item * Revert fs client argument changes * Fix mypy issue * Fix linting issues * Use all aggregated placeholder when checking layout * Enable auto_mkdir for filesystem config tests * Enable auto_mkdir for filesystem config tests * Enable auto_mkdir only for local filesystem * Add more placeholders * Remove extra layout options * Accepts current_datetime * Adjust type names * Pass current datetime * Resolve current_datetime if it is callable * Fix linting issues * Parametrize layout check test * Fix mypy issues * Fix mypy issues * Add more tests * Add more test checks for create_path flow * Add test flow comment * Add more tests * Add test to check callable extra placeholders * Test if unused layout placeholders are printed * Adjust timestamp selection logic * Fix linting issues * Extend default placeholders and remove redundant ones * Add quarter placeholder * Add test case for layout with quarter of the year * Adjust type alias for placeholder callback * Simplify code * Adjust tests * Validate placeholders in on_resolve * Avoid assigning current_datetime callback value during validation * Fix mypy warnings * Fix linting issues * Log warning message with yellow foreground * Remove useless test * Adjust error message for placeholder callback functions * Lowercase formatted datetime values * Adjust comments * Re-import load_storage * Adjust logic around timestamp and current datetime * Fix mypy warnings * Add test configuraion override when values passed via filesystem factory * Better logic to handle current timestamp and current datetime * Add more test checks * Introduce new InvalidPlaceholderCallback exception * Raise InvalidPlaceholderCallback instead of plain TypeError * Fix import ban error * Add more test cases for path utils layout check * Adjust text color * Small cleanup * Verify path parts and layout parts are equal * Remove unnecessary log * Add test with actual pipeline run and checks for callback calls * Revert conftest changes * Cleanup and move current_datetime calling inside path_utils * Adjust test * Add clarification comment * Use logger instead of printing out * Make InvalidPlaceholderCallback of transient kind * Move tests to new place * Cleanup * Add load_package_timestamp placeholder * Fix mypy warning * Add pytest-mock * Adjust tests * Adjust logic * Fix mypy issue * Use spy on logger for a test * Add test layout example with load_package_timestamp * Add test layout example with load_package_timestamp in pipeline tests * Check created paths and assert validity of placeholders * Rename variable to better fit the context * Assert arguments in extra placeholder callbacks * Make invalid placeholders exception more useful * Assert created path with spy values * Make error messages better for InvalidFilesystemLayout exception * Fix mypy errors * Also check created path * Run pipeline then create path and check if files exist * Fix mypy errors * Check all load packages * Add more layout samples using custom placeholders * Add more layout samples with callable extra placeholder * Add more layout samples with callable extra placeholder * Remove redundant import * Check expected paths created by create_path * Fix mypy issues * Add explanation comment to ALL_LAYOUTS * Re-use frozen datetime * Use dlt.common.pendulum * Use ensure_pendulum_datetime instead of pendulum.parse * Fix mypy issues * Add invalid layout with extra placeholder before table_name * Adjust exception message from invalid to missing placeholders --- dlt/destinations/exceptions.py | 37 +- dlt/destinations/impl/dummy/dummy.py | 3 +- .../impl/filesystem/configuration.py | 28 +- .../impl/filesystem/filesystem.py | 37 +- dlt/destinations/impl/filesystem/typing.py | 19 + dlt/destinations/path_utils.py | 222 +++++++- poetry.lock | 240 +++++++- pyproject.toml | 1 + tests/destinations/test_path_utils.py | 512 +++++++++++++++++- .../load/filesystem/test_filesystem_client.py | 79 ++- .../load/filesystem/test_filesystem_common.py | 88 ++- .../load/pipeline/test_filesystem_pipeline.py | 109 +++- tests/load/test_dummy_client.py | 11 +- 13 files changed, 1279 insertions(+), 107 deletions(-) create mode 100644 dlt/destinations/impl/filesystem/typing.py diff --git a/dlt/destinations/exceptions.py b/dlt/destinations/exceptions.py index 5e6adb007d..3b3e602b57 100644 --- a/dlt/destinations/exceptions.py +++ b/dlt/destinations/exceptions.py @@ -112,9 +112,42 @@ def __init__( class InvalidFilesystemLayout(DestinationTerminalException): - def __init__(self, invalid_placeholders: Sequence[str]) -> None: + def __init__( + self, + layout: str, + expected_placeholders: Sequence[str], + extra_placeholders: Sequence[str], + invalid_placeholders: Sequence[str], + unused_placeholders: Sequence[str], + ) -> None: self.invalid_placeholders = invalid_placeholders - super().__init__(f"Invalid placeholders found in filesystem layout: {invalid_placeholders}") + self.extra_placeholders = extra_placeholders + self.expected_placeholders = expected_placeholders + self.unused_placeholders = unused_placeholders + self.layout = layout + + message = ( + f"Layout '{layout}' expected {', '.join(expected_placeholders)} placeholders." + f"Missing placeholders: {', '.join(invalid_placeholders)}." + ) + + if extra_placeholders: + message += f"Extra placeholders specified: {', '.join(extra_placeholders)}." + + if unused_placeholders: + message += f"Unused placeholders: {', '.join(unused_placeholders)}." + + super().__init__(message) + + +class InvalidPlaceholderCallback(DestinationTransientException): + def __init__(self, callback_name: str) -> None: + self.callback_name = callback_name + super().__init__( + f"Invalid placeholder callback: {callback_name}, please make sure it can" + " accept parameters the following `schema name`, `table name`," + " `load_id`, `file_id` and an `extension`", + ) class CantExtractTablePrefix(DestinationTerminalException): diff --git a/dlt/destinations/impl/dummy/dummy.py b/dlt/destinations/impl/dummy/dummy.py index bafac210cc..47ae25828e 100644 --- a/dlt/destinations/impl/dummy/dummy.py +++ b/dlt/destinations/impl/dummy/dummy.py @@ -1,10 +1,9 @@ -from contextlib import contextmanager import random +from contextlib import contextmanager from copy import copy from types import TracebackType from typing import ( ClassVar, - ContextManager, Dict, Iterator, Optional, diff --git a/dlt/destinations/impl/filesystem/configuration.py b/dlt/destinations/impl/filesystem/configuration.py index 1521222180..09dc40e9d4 100644 --- a/dlt/destinations/impl/filesystem/configuration.py +++ b/dlt/destinations/impl/filesystem/configuration.py @@ -1,19 +1,41 @@ import dataclasses -from typing import Final, Type, Optional +from typing import Final, List, Optional, Type + +from dlt.common import logger from dlt.common.configuration import configspec, resolve_type from dlt.common.destination.reference import ( CredentialsConfiguration, DestinationClientStagingConfiguration, ) + from dlt.common.storages import FilesystemConfiguration +from dlt.destinations.impl.filesystem.typing import TCurrentDateTime, TExtraPlaceholders + +from dlt.destinations.path_utils import check_layout, get_unused_placeholders @configspec class FilesystemDestinationClientConfiguration(FilesystemConfiguration, DestinationClientStagingConfiguration): # type: ignore[misc] - destination_type: Final[str] = dataclasses.field(default="filesystem", init=False, repr=False, compare=False) # type: ignore + destination_type: Final[str] = dataclasses.field( # type: ignore[misc] + default="filesystem", init=False, repr=False, compare=False + ) + current_datetime: Optional[TCurrentDateTime] = None + extra_placeholders: Optional[TExtraPlaceholders] = None @resolve_type("credentials") def resolve_credentials_type(self) -> Type[CredentialsConfiguration]: # use known credentials or empty credentials for unknown protocol - return self.PROTOCOL_CREDENTIALS.get(self.protocol) or Optional[CredentialsConfiguration] # type: ignore[return-value] + return ( + self.PROTOCOL_CREDENTIALS.get(self.protocol) + or Optional[CredentialsConfiguration] # type: ignore[return-value] + ) + + def on_resolved(self) -> None: + # Validate layout and show unused placeholders + _, layout_placeholders = check_layout(self.layout, self.extra_placeholders) + unused_placeholders = get_unused_placeholders( + layout_placeholders, list((self.extra_placeholders or {}).keys()) + ) + if unused_placeholders: + logger.info(f"Found unused layout placeholders: {', '.join(unused_placeholders)}") diff --git a/dlt/destinations/impl/filesystem/filesystem.py b/dlt/destinations/impl/filesystem/filesystem.py index 33a597f915..f06cf5ae54 100644 --- a/dlt/destinations/impl/filesystem/filesystem.py +++ b/dlt/destinations/impl/filesystem/filesystem.py @@ -5,9 +5,10 @@ from fsspec import AbstractFileSystem from contextlib import contextmanager +import dlt from dlt.common import logger from dlt.common.schema import Schema, TSchemaTables, TTableSchema -from dlt.common.storages import FileStorage, ParsedLoadJobFileName, fsspec_from_config +from dlt.common.storages import FileStorage, fsspec_from_config from dlt.common.destination import DestinationCapabilitiesContext from dlt.common.destination.reference import ( NewLoadJob, @@ -38,32 +39,30 @@ def __init__( file_name = FileStorage.get_file_name_from_file_path(local_path) self.config = config self.dataset_path = dataset_path - self.destination_file_name = LoadFilesystemJob.make_destination_filename( - config.layout, file_name, schema_name, load_id + self.destination_file_name = path_utils.create_path( + config.layout, + file_name, + schema_name, + load_id, + current_datetime=config.current_datetime, + load_package_timestamp=dlt.current.load_package()["state"]["created_at"], # type: ignore + extra_placeholders=config.extra_placeholders, ) super().__init__(file_name) fs_client, _ = fsspec_from_config(config) - self.destination_file_name = LoadFilesystemJob.make_destination_filename( - config.layout, file_name, schema_name, load_id + self.destination_file_name = path_utils.create_path( + config.layout, + file_name, + schema_name, + load_id, + current_datetime=config.current_datetime, + load_package_timestamp=dlt.current.load_package()["state"]["created_at"], # type: ignore + extra_placeholders=config.extra_placeholders, ) item = self.make_remote_path() fs_client.put_file(local_path, item) - @staticmethod - def make_destination_filename( - layout: str, file_name: str, schema_name: str, load_id: str - ) -> str: - job_info = ParsedLoadJobFileName.parse(file_name) - return path_utils.create_path( - layout, - schema_name=schema_name, - table_name=job_info.table_name, - load_id=load_id, - file_id=job_info.file_id, - ext=job_info.file_format, - ) - def make_remote_path(self) -> str: return ( f"{self.config.protocol}://{posixpath.join(self.dataset_path, self.destination_file_name)}" diff --git a/dlt/destinations/impl/filesystem/typing.py b/dlt/destinations/impl/filesystem/typing.py new file mode 100644 index 0000000000..139602198d --- /dev/null +++ b/dlt/destinations/impl/filesystem/typing.py @@ -0,0 +1,19 @@ +from typing import Callable, Dict, Union + +from pendulum.datetime import DateTime +from typing_extensions import TypeAlias + + +TCurrentDateTimeCallback: TypeAlias = Callable[[], DateTime] +"""A callback function to which should return pendulum.DateTime instance""" + +TCurrentDateTime: TypeAlias = Union[DateTime, TCurrentDateTimeCallback] +"""pendulum.DateTime instance or a callable which should return pendulum.DateTime""" + +TLayoutPlaceholderCallback: TypeAlias = Callable[[str, str, str, str, str], str] +"""A callback which should return prepared string value the following arguments passed +`schema name`, `table name`, `load_id`, `file_id` and an `extension` +""" + +TExtraPlaceholders: TypeAlias = Dict[str, Union[str, TLayoutPlaceholderCallback]] +"""Extra placeholders for filesystem layout""" diff --git a/dlt/destinations/path_utils.py b/dlt/destinations/path_utils.py index 5b2ba9d183..bd870ba995 100644 --- a/dlt/destinations/path_utils.py +++ b/dlt/destinations/path_utils.py @@ -1,46 +1,221 @@ -# this can probably go some other place, but it is shared by destinations, so for now it is here -from typing import List, Sequence, Tuple - import re +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from dlt.common import logger from dlt.common.pendulum import pendulum +from dlt.common.storages.load_package import ParsedLoadJobFileName +from dlt.common.time import ensure_pendulum_datetime +from dlt.destinations.exceptions import ( + CantExtractTablePrefix, + InvalidFilesystemLayout, + InvalidPlaceholderCallback, +) +from dlt.destinations.impl.filesystem.typing import TCurrentDateTime -from dlt.destinations.exceptions import InvalidFilesystemLayout, CantExtractTablePrefix -# TODO: ensure layout only has supported placeholders -SUPPORTED_PLACEHOLDERS = {"schema_name", "table_name", "load_id", "file_id", "ext", "curr_date"} +# For formatting options please see +# https://github.com/sdispater/pendulum/blob/master/docs/docs/string_formatting.md +DATETIME_PLACEHOLDERS = { + # Years + "YYYY", # 2024, 2025, 2026 + "YY", # 00, 01, 02 ... 12, 13 + "Y", # 2024, 2025, 2026 + # Months + "MMMM", # January, February, March + "MMM", # Jan, Feb, Mar + "MM", # 01-12 + "M", # 1-12 + # Days + "DD", # 01-31 + "D", # 1-31 + # Hours + "HH", # 00-23 + "H", # 0-23 + # Minutes + "mm", # 00-59 + "m", # 0-59 + # Days of week + "dddd", # Monday, Tuesday, Wednesday + "ddd", # Mon, Tue, Wed + "dd", # Mo, Tu, We + "d", # 0-6 + # Quarters of the year + "Q", # 1, 2, 3, 4 +} -SUPPORTED_TABLE_NAME_PREFIX_PLACEHOLDERS = ("schema_name",) +STANDARD_PLACEHOLDERS = DATETIME_PLACEHOLDERS.union( + { + "schema_name", + "table_name", + "load_id", + "file_id", + "ext", + "curr_date", + "timestamp", + "load_package_timestamp", + } +) -def check_layout(layout: str) -> List[str]: - placeholders = get_placeholders(layout) - invalid_placeholders = [p for p in placeholders if p not in SUPPORTED_PLACEHOLDERS] - if invalid_placeholders: - raise InvalidFilesystemLayout(invalid_placeholders) - return placeholders +SUPPORTED_TABLE_NAME_PREFIX_PLACEHOLDERS = ("schema_name",) def get_placeholders(layout: str) -> List[str]: return re.findall(r"\{(.*?)\}", layout) +def get_unused_placeholders( + layout_placeholders: Sequence[str], + extra_placeholders: Sequence[str], +) -> Sequence[str]: + unused_placeholders = [p for p in extra_placeholders if p not in layout_placeholders] + return unused_placeholders + + +def prepare_datetime_params( + current_datetime: Optional[pendulum.DateTime] = None, + load_package_timestamp: Optional[str] = None, +) -> Dict[str, str]: + params: Dict[str, str] = {} + current_timestamp: pendulum.DateTime = None + if load_package_timestamp: + current_timestamp = ensure_pendulum_datetime(load_package_timestamp) + params["load_package_timestamp"] = str(int(current_timestamp.timestamp())) + + if not current_datetime: + if current_timestamp: + logger.info("current_datetime is not set, using timestamp from load package") + current_datetime = current_timestamp + else: + logger.info("current_datetime is not set, using pendulum.now()") + current_datetime = pendulum.now() + + params["timestamp"] = str(int(current_datetime.timestamp())) + params["curr_date"] = str(current_datetime.date()) + + for format_string in DATETIME_PLACEHOLDERS: + params[format_string] = current_datetime.format(format_string).lower() + + return params + + +def prepare_params( + extra_placeholders: Optional[Dict[str, Any]] = None, + job_info: Optional[ParsedLoadJobFileName] = None, + schema_name: Optional[str] = None, + load_id: Optional[str] = None, +) -> Dict[str, Any]: + params: Dict[str, Any] = {} + table_name = None + file_id = None + ext = None + if job_info: + table_name = job_info.table_name + file_id = job_info.file_id + ext = job_info.file_format + params.update( + { + "table_name": table_name, + "file_id": file_id, + "ext": ext, + } + ) + + if schema_name: + params["schema_name"] = schema_name + + if load_id: + params["load_id"] = load_id + + # Resolve extra placeholders + if extra_placeholders: + for key, value in extra_placeholders.items(): + if callable(value): + try: + params[key] = value( + schema_name, + table_name, + load_id, + file_id, + ext, + ) + except TypeError as exc: + raise InvalidPlaceholderCallback(key) from exc + else: + params[key] = value + + return params + + +def check_layout( + layout: str, + extra_placeholders: Optional[Dict[str, Any]] = None, + standard_placeholders: Optional[Set[str]] = STANDARD_PLACEHOLDERS, +) -> Tuple[List[str], List[str]]: + """Returns a tuple with all valid placeholders and the list of layout placeholders + + Raises: InvalidFilesystemLayout + + Returns: a pair of lists of valid placeholders and layout placeholders + """ + placeholders = get_placeholders(layout) + # Build out the list of placeholder names + # which we will use to validate placeholders + # in a given config.layout template + all_placeholders = standard_placeholders.copy() + if extra_placeholders: + for placeholder, _ in extra_placeholders.items(): + all_placeholders.add(placeholder) + + # now collect all unknown placeholders from config.layout template + invalid_placeholders = [p for p in placeholders if p not in all_placeholders] + extra_placeholder_keys = list((extra_placeholders or {}).keys()) + unused_placeholders = get_unused_placeholders(placeholders, extra_placeholder_keys) + if invalid_placeholders: + raise InvalidFilesystemLayout( + layout, + all_placeholders, # type: ignore[arg-type] + extra_placeholder_keys, + invalid_placeholders, + unused_placeholders, + ) + + return list(all_placeholders), placeholders + + def create_path( - layout: str, schema_name: str, table_name: str, load_id: str, file_id: str, ext: str + layout: str, + file_name: str, + schema_name: str, + load_id: str, + load_package_timestamp: Optional[str] = None, + current_datetime: Optional[TCurrentDateTime] = None, + extra_placeholders: Optional[Dict[str, Any]] = None, ) -> str: """create a filepath from the layout and our default params""" - placeholders = check_layout(layout) - path = layout.format( + if callable(current_datetime): + current_datetime = current_datetime() + if not isinstance(current_datetime, pendulum.DateTime): + raise RuntimeError("current_datetime is not an instance instance of pendulum.DateTime") + + job_info = ParsedLoadJobFileName.parse(file_name) + params = prepare_params( + extra_placeholders=extra_placeholders, + job_info=job_info, schema_name=schema_name, - table_name=table_name, load_id=load_id, - file_id=file_id, - ext=ext, - curr_date=str(pendulum.today()), ) + + datetime_params = prepare_datetime_params(current_datetime, load_package_timestamp) + params.update(datetime_params) + + placeholders, _ = check_layout(layout, params) + path = layout.format(**params) + # if extension is not defined, we append it at the end if "ext" not in placeholders: - path += f".{ext}" + path += f".{job_info.file_format}" + return path @@ -49,14 +224,13 @@ def get_table_prefix_layout( supported_prefix_placeholders: Sequence[str] = SUPPORTED_TABLE_NAME_PREFIX_PLACEHOLDERS, ) -> str: """get layout fragment that defines positions of the table, cutting other placeholders - allowed `supported_prefix_placeholders` that may appear before table. """ placeholders = get_placeholders(layout) - # fail if table name is not defined if "table_name" not in placeholders: raise CantExtractTablePrefix(layout, "{table_name} placeholder not found. ") + table_name_index = placeholders.index("table_name") # fail if any other prefix is defined before table_name @@ -74,7 +248,7 @@ def get_table_prefix_layout( raise CantExtractTablePrefix(layout, details) # we include the char after the table_name here, this should be a separator not a new placeholder - # this is to prevent selecting tables that have the same starting name + # this is to prevent selecting tables that have the same starting name -> {table_name}/ prefix = layout[: layout.index("{table_name}") + 13] if prefix[-1] == "{": raise CantExtractTablePrefix(layout, "A separator is required after a {table_name}. ") diff --git a/poetry.lock b/poetry.lock index a05cb28c1b..366332ecbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3357,6 +3357,214 @@ files = [ {file = "google_re2-1.1-1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6c9f64b9724ec38da8e514f404ac64e9a6a5e8b1d7031c2dadd05c1f4c16fd"}, {file = "google_re2-1.1-1-cp39-cp39-win32.whl", hash = "sha256:d1b751b9ab9f8e2ab2a36d72b909281ce65f328c9115a1685acae1a2d1afd7a4"}, {file = "google_re2-1.1-1-cp39-cp39-win_amd64.whl", hash = "sha256:ac775c75cec7069351d201da4e0fb0cae4c1c5ebecd08fa34e1be89740c1d80b"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5eaefe4705b75ca5f78178a50104b689e9282f868e12f119b26b4cffc0c7ee6e"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:e35f2c8aabfaaa4ce6420b3cae86c0c29042b1b4f9937254347e9b985694a171"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:35fd189cbaaaa39c9a6a8a00164c8d9c709bacd0c231c694936879609beff516"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:60475d222cebd066c80414831c8a42aa2449aab252084102ee05440896586e6a"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:871cb85b9b0e1784c983b5c148156b3c5314cb29ca70432dff0d163c5c08d7e5"}, + {file = "google_re2-1.1-2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:94f4e66e34bdb8de91ec6cdf20ba4fa9fea1dfdcfb77ff1f59700d01a0243664"}, + {file = "google_re2-1.1-2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1563577e2b720d267c4cffacc0f6a2b5c8480ea966ebdb1844fbea6602c7496f"}, + {file = "google_re2-1.1-2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49b7964532a801b96062d78c0222d155873968f823a546a3dbe63d73f25bb56f"}, + {file = "google_re2-1.1-2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2362fd70eb639a75fd0187d28b4ba7b20b3088833d8ad7ffd8693d0ba159e1c2"}, + {file = "google_re2-1.1-2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86b80719636a4e21391e20a9adf18173ee6ae2ec956726fe2ff587417b5e8ba6"}, + {file = "google_re2-1.1-2-cp310-cp310-win32.whl", hash = "sha256:5456fba09df951fe8d1714474ed1ecda102a68ddffab0113e6c117d2e64e6f2b"}, + {file = "google_re2-1.1-2-cp310-cp310-win_amd64.whl", hash = "sha256:2ac6936a3a60d8d9de9563e90227b3aea27068f597274ca192c999a12d8baa8f"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5a87b436028ec9b0f02fe19d4cbc19ef30441085cdfcdf1cce8fbe5c4bd5e9a"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:fc0d4163de9ed2155a77e7a2d59d94c348a6bbab3cff88922fab9e0d3d24faec"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:48b12d953bc796736e7831d67b36892fb6419a4cc44cb16521fe291e594bfe23"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:62c780c927cff98c1538439f0ff616f48a9b2e8837c676f53170d8ae5b9e83cb"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:04b2aefd768aa4edeef8b273327806c9cb0b82e90ff52eacf5d11003ac7a0db2"}, + {file = "google_re2-1.1-2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:9c90175992346519ee7546d9af9a64541c05b6b70346b0ddc54a48aa0d3b6554"}, + {file = "google_re2-1.1-2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22ad9ad9d125249d6386a2e80efb9de7af8260b703b6be7fa0ab069c1cf56ced"}, + {file = "google_re2-1.1-2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70971f6ffe5254e476e71d449089917f50ebf9cf60f9cec80975ab1693777e2"}, + {file = "google_re2-1.1-2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f267499529e64a4abed24c588f355ebe4700189d434d84a7367725f5a186e48d"}, + {file = "google_re2-1.1-2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b632eff5e4cd44545a9c0e52f2e1becd55831e25f4dd4e0d7ec8ee6ca50858c1"}, + {file = "google_re2-1.1-2-cp311-cp311-win32.whl", hash = "sha256:a42c733036e8f242ee4e5f0e27153ad4ca44ced9e4ce82f3972938ddee528db0"}, + {file = "google_re2-1.1-2-cp311-cp311-win_amd64.whl", hash = "sha256:64f8eed4ca96905d99b5286b3d14b5ca4f6a025ff3c1351626a7df2f93ad1ddd"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5541efcca5b5faf7e0d882334a04fa479bad4e7433f94870f46272eec0672c4a"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:92309af35b6eb2d3b3dc57045cdd83a76370958ab3e0edd2cc4638f6d23f5b32"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:197cd9bcaba96d18c5bf84d0c32fca7a26c234ea83b1d3083366f4392cb99f78"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:1b896f171d29b541256cf26e10dccc9103ac1894683914ed88828ca6facf8dca"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_13_0_arm64.whl", hash = "sha256:e022d3239b945014e916ca7120fee659b246ec26c301f9e0542f1a19b38a8744"}, + {file = "google_re2-1.1-2-cp38-cp38-macosx_13_0_x86_64.whl", hash = "sha256:2c73f8a9440873b68bee1198094377501065e85aaf6fcc0d2512c7589ffa06ca"}, + {file = "google_re2-1.1-2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:901d86555bd7725506d651afaba7d71cd4abd13260aed6cfd7c641a45f76d4f6"}, + {file = "google_re2-1.1-2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce4710ff636701cfb56eb91c19b775d53b03749a23b7d2a5071bbbf4342a9067"}, + {file = "google_re2-1.1-2-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76a20e5ebdf5bc5d430530197e42a2eeb562f729d3a3fb51f39168283d676e66"}, + {file = "google_re2-1.1-2-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c9f4d4bb1c8de9d2642d3c4b8b615858ba764df025b3b4f1310266f8def269"}, + {file = "google_re2-1.1-2-cp38-cp38-win32.whl", hash = "sha256:94bd60785bf37ef130a1613738e3c39465a67eae3f3be44bb918540d39b68da3"}, + {file = "google_re2-1.1-2-cp38-cp38-win_amd64.whl", hash = "sha256:59efeb77c0dcdbe37794c61f29c5b1f34bc06e8ec309a111ccdd29d380644d70"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:221e38c27e1dd9ccb8e911e9c7aed6439f68ce81e7bb74001076830b0d6e931d"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:d9145879e6c2e1b814445300b31f88a675e1f06c57564670d95a1442e8370c27"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c8a12f0740e2a52826bdbf95569a4b0abdf413b4012fa71e94ad25dd4715c6e5"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9c9998f71466f4db7bda752aa7c348b2881ff688e361108fe500caad1d8b9cb2"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_13_0_arm64.whl", hash = "sha256:0c39f69b702005963a3d3bf78743e1733ad73efd7e6e8465d76e3009e4694ceb"}, + {file = "google_re2-1.1-2-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:6d0ce762dee8d6617d0b1788a9653e805e83a23046c441d0ea65f1e27bf84114"}, + {file = "google_re2-1.1-2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ecf3619d98c9b4a7844ab52552ad32597cdbc9a5bdbc7e3435391c653600d1e2"}, + {file = "google_re2-1.1-2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a1426a8cbd1fa004974574708d496005bd379310c4b1c7012be4bc75efde7a8"}, + {file = "google_re2-1.1-2-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a30626ba48b4070f3eab272d860ef1952e710b088792c4d68dddb155be6bfc"}, + {file = "google_re2-1.1-2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b9c1ffcfbc3095b6ff601ec2d2bf662988f6ea6763bc1c9d52bec55881f8fde"}, + {file = "google_re2-1.1-2-cp39-cp39-win32.whl", hash = "sha256:32ecf995a252c0548404c1065ba4b36f1e524f1f4a86b6367a1a6c3da3801e30"}, + {file = "google_re2-1.1-2-cp39-cp39-win_amd64.whl", hash = "sha256:e7865410f3b112a3609739283ec3f4f6f25aae827ff59c6bfdf806fd394d753e"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3b21f83f0a201009c56f06fcc7294a33555ede97130e8a91b3f4cae01aed1d73"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b38194b91354a38db1f86f25d09cdc6ac85d63aee4c67b43da3048ce637adf45"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e7da3da8d6b5a18d6c3b61b11cc5b66b8564eaedce99d2312b15b6487730fc76"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:aeca656fb10d8638f245331aabab59c9e7e051ca974b366dd79e6a9efb12e401"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:2069d6dc94f5fa14a159bf99cad2f11e9c0f8ec3b7f44a4dde9e59afe5d1c786"}, + {file = "google_re2-1.1-3-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:2319a39305a4931cb5251451f2582713418a19bef2af7adf9e2a7a0edd939b99"}, + {file = "google_re2-1.1-3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb98fc131699756c6d86246f670a5e1c1cc1ba85413c425ad344cb30479b246c"}, + {file = "google_re2-1.1-3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6e038986d8ffe4e269f8532f03009f229d1f6018d4ac0dabc8aff876338f6e0"}, + {file = "google_re2-1.1-3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8618343ee658310e0f53bf586fab7409de43ce82bf8d9f7eb119536adc9783fd"}, + {file = "google_re2-1.1-3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8140ca861cfe00602319cefe2c7b8737b379eb07fb328b51dc44584f47a2718"}, + {file = "google_re2-1.1-3-cp310-cp310-win32.whl", hash = "sha256:41f439c5c54e8a3a0a1fa2dbd1e809d3f643f862df7b16dd790f36a1238a272e"}, + {file = "google_re2-1.1-3-cp310-cp310-win_amd64.whl", hash = "sha256:fe20e97a33176d96d3e4b5b401de35182b9505823abea51425ec011f53ef5e56"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c39ff52b1765db039f690ee5b7b23919d8535aae94db7996079fbde0098c4d7"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5420be674fd164041639ba4c825450f3d4bd635572acdde16b3dcd697f8aa3ef"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ff53881cf1ce040f102a42d39db93c3f835f522337ae9c79839a842f26d97733"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:8d04600b0b53523118df2e413a71417c408f20dee640bf07dfab601c96a18a77"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:c4835d4849faa34a7fa1074098d81c420ed6c0707a3772482b02ce14f2a7c007"}, + {file = "google_re2-1.1-3-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:3309a9b81251d35fee15974d0ae0581a9a375266deeafdc3a3ac0d172a742357"}, + {file = "google_re2-1.1-3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b51cafee7e0bc72d0a4a454547bd8f257cde412ac9f1a2dc46a203b5e42cf4"}, + {file = "google_re2-1.1-3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83f5f1cb52f832c2297d271ee8c56cf5e9053448162e5d2223d513f729bad908"}, + {file = "google_re2-1.1-3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55865a1ace92be3f7953b2e2b38b901d8074a367aa491daee43260a53a7fc6f0"}, + {file = "google_re2-1.1-3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cec2167dd142e583e98c783bd0d28b8cf5a9cdbe1f7407ba4163fe3ccb613cb9"}, + {file = "google_re2-1.1-3-cp311-cp311-win32.whl", hash = "sha256:a0bc1fe96849e4eb8b726d0bba493f5b989372243b32fe20729cace02e5a214d"}, + {file = "google_re2-1.1-3-cp311-cp311-win_amd64.whl", hash = "sha256:e6310a156db96fc5957cb007dd2feb18476898654530683897469447df73a7cd"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e63cd10ea006088b320e8c5d308da1f6c87aa95138a71c60dd7ca1c8e91927e"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:12b566830a334178733a85e416b1e0507dbc0ceb322827616fe51ef56c5154f1"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:442e18c9d46b225c1496919c16eafe8f8d9bb4091b00b4d3440da03c55bbf4ed"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:c54c00263a9c39b2dacd93e9636319af51e3cf885c080b9680a9631708326460"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:15a3caeeb327bc22e0c9f95eb76890fec8874cacccd2b01ff5c080ab4819bbec"}, + {file = "google_re2-1.1-3-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:59ec0d2cced77f715d41f6eafd901f6b15c11e28ba25fe0effdc1de554d78e75"}, + {file = "google_re2-1.1-3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:185bf0e3441aed3840590f8e42f916e2920d235eb14df2cbc2049526803d3e71"}, + {file = "google_re2-1.1-3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:586d3f2014eea5be14d8de53374d9b79fa99689160e00efa64b5fe93af326087"}, + {file = "google_re2-1.1-3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc2575082de4ffd234d9607f3ae67ca22b15a1a88793240e2045f3b3a36a5795"}, + {file = "google_re2-1.1-3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59c5ad438eddb3630def394456091284d7bbc5b89351987f94f3792d296d1f96"}, + {file = "google_re2-1.1-3-cp312-cp312-win32.whl", hash = "sha256:5b9878c53f2bf16f75bf71d4ddd57f6611351408d5821040e91c53ebdf82c373"}, + {file = "google_re2-1.1-3-cp312-cp312-win_amd64.whl", hash = "sha256:4fdecfeb213110d0a85bad335a8e7cdb59fea7de81a4fe659233f487171980f9"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2dd87bacab32b709c28d0145fe75a956b6a39e28f0726d867375dba5721c76c1"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:55d24c61fe35dddc1bb484593a57c9f60f9e66d7f31f091ef9608ed0b6dde79f"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a0cf1180d908622df648c26b0cd09281f92129805ccc56a39227fdbfeab95cb4"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:09586f07f3f88d432265c75976da1c619ab7192cd7ebdf53f4ae0776c19e4b56"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_13_0_arm64.whl", hash = "sha256:539f1b053402203576e919a06749198da4ae415931ee28948a1898131ae932ce"}, + {file = "google_re2-1.1-3-cp38-cp38-macosx_13_0_x86_64.whl", hash = "sha256:abf0bcb5365b0e27a5a23f3da403dffdbbac2c0e3a3f1535a8b10cc121b5d5fb"}, + {file = "google_re2-1.1-3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19c83e5bbed7958213eeac3aa71c506525ce54faf03e07d0b96cd0a764890511"}, + {file = "google_re2-1.1-3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3348e77330ff672dc44ec01894fa5d93c409a532b6d688feac55e714e9059920"}, + {file = "google_re2-1.1-3-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06b63edb57c5ce5a13eabfd71155e346b9477dc8906dec7c580d4f70c16a7e0d"}, + {file = "google_re2-1.1-3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12fe57ba2914092b83338d61d8def9ebd5a2bd0fd8679eceb5d4c2748105d5c0"}, + {file = "google_re2-1.1-3-cp38-cp38-win32.whl", hash = "sha256:80796e08d24e606e675019fe8de4eb5c94bb765be13c384f2695247d54a6df75"}, + {file = "google_re2-1.1-3-cp38-cp38-win_amd64.whl", hash = "sha256:3c2257dedfe7cc5deb6791e563af9e071a9d414dad89e37ac7ad22f91be171a9"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43a0cd77c87c894f28969ac622f94b2e6d1571261dfdd785026848a25cfdc9b9"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:1038990b77fd66f279bd66a0832b67435ea925e15bb59eafc7b60fdec812b616"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fb5dda6875d18dd45f0f24ebced6d1f7388867c8fb04a235d1deab7ea479ce38"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb1d164965c6d57a351b421d2f77c051403766a8b75aaa602324ee2451fff77f"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_13_0_arm64.whl", hash = "sha256:a072ebfa495051d07ffecbf6ce21eb84793568d5c3c678c00ed8ff6b8066ab31"}, + {file = "google_re2-1.1-3-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:4eb66c8398c8a510adc97978d944b3b29c91181237218841ea1a91dc39ec0e54"}, + {file = "google_re2-1.1-3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f7c8b57b1f559553248d1757b7fa5b2e0cc845666738d155dff1987c2618264e"}, + {file = "google_re2-1.1-3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9162f6aa4f25453c682eb176f21b8e2f40205be9f667e98a54b3e1ff10d6ee75"}, + {file = "google_re2-1.1-3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d65ddf67fd7bf94705626871d463057d3d9a3538d41022f95b9d8f01df36e1"}, + {file = "google_re2-1.1-3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d140c7b9395b4d1e654127aa1c99bcc603ed01000b7bc7e28c52562f1894ec12"}, + {file = "google_re2-1.1-3-cp39-cp39-win32.whl", hash = "sha256:80c5fc200f64b2d903eeb07b8d6cefc620a872a0240c7caaa9aca05b20f5568f"}, + {file = "google_re2-1.1-3-cp39-cp39-win_amd64.whl", hash = "sha256:9eb6dbcee9b5dc4069bbc0634f2eb039ca524a14bed5868fdf6560aaafcbca06"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0db114d7e1aa96dbcea452a40136d7d747d60cbb61394965774688ef59cccd4e"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:82133958e003a1344e5b7a791b9a9dd7560b5c8f96936dbe16f294604524a633"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:9e74fd441d1f3d917d3303e319f61b82cdbd96b9a5ba919377a6eef1504a1e2b"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:734a2e7a4541c57253b5ebee24f3f3366ba3658bcad01da25fb623c78723471a"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:d88d5eecbc908abe16132456fae13690d0508f3ac5777f320ef95cb6cab9a961"}, + {file = "google_re2-1.1-4-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:b91db80b171ecec435a07977a227757dd487356701a32f556fa6fca5d0a40522"}, + {file = "google_re2-1.1-4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b23129887a64bb9948af14c84705273ed1a40054e99433b4acccab4dcf6a226"}, + {file = "google_re2-1.1-4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dc1a0cc7cd19261dcaf76763e2499305dbb7e51dc69555167cdb8af98782698"}, + {file = "google_re2-1.1-4-cp310-cp310-win32.whl", hash = "sha256:3b2ab1e2420b5dd9743a2d6bc61b64e5f708563702a75b6db86637837eaeaf2f"}, + {file = "google_re2-1.1-4-cp310-cp310-win_amd64.whl", hash = "sha256:92efca1a7ef83b6df012d432a1cbc71d10ff42200640c0f9a5ff5b343a48e633"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:854818fd4ce79787aca5ba459d6e5abe4ca9be2c684a5b06a7f1757452ca3708"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:4ceef51174b6f653b6659a8fdaa9c38960c5228b44b25be2a3bcd8566827554f"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:ee49087c3db7e6f5238105ab5299c09e9b77516fe8cfb0a37e5f1e813d76ecb8"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:dc2312854bdc01410acc5d935f1906a49cb1f28980341c20a68797ad89d8e178"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0dc0d2e42296fa84a3cb3e1bd667c6969389cd5cdf0786e6b1f911ae2d75375b"}, + {file = "google_re2-1.1-4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6bf04ced98453b035f84320f348f67578024f44d2997498def149054eb860ae8"}, + {file = "google_re2-1.1-4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d6b6ef11dc4ab322fa66c2f3561925f2b5372a879c3ed764d20e939e2fd3e5f"}, + {file = "google_re2-1.1-4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0dcde6646fa9a97fd3692b3f6ae7daf7f3277d7500b6c253badeefa11db8956a"}, + {file = "google_re2-1.1-4-cp311-cp311-win32.whl", hash = "sha256:5f4f0229deb057348893574d5b0a96d055abebac6debf29d95b0c0e26524c9f6"}, + {file = "google_re2-1.1-4-cp311-cp311-win_amd64.whl", hash = "sha256:4713ddbe48a18875270b36a462b0eada5e84d6826f8df7edd328d8706b6f9d07"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:40a698300b8faddbb325662973f839489c89b960087060bd389c376828978a04"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:103d2d7ac92ba23911a151fd1fc7035cbf6dc92a7f6aea92270ebceb5cd5acd3"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:51fb7182bccab05e8258a2b6a63dda1a6b4a9e8dfb9b03ec50e50c49c2827dd4"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:65383022abd63d7b620221eba7935132b53244b8b463d8fdce498c93cf58b7b7"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396281fc68a9337157b3ffcd9392c6b7fcb8aab43e5bdab496262a81d56a4ecc"}, + {file = "google_re2-1.1-4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8198adcfcff1c680e052044124621730fc48d08005f90a75487f5651f1ebfce2"}, + {file = "google_re2-1.1-4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81f7bff07c448aec4db9ca453d2126ece8710dbd9278b8bb09642045d3402a96"}, + {file = "google_re2-1.1-4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7dacf730fd7d6ec71b11d6404b0b26e230814bfc8e9bb0d3f13bec9b5531f8d"}, + {file = "google_re2-1.1-4-cp312-cp312-win32.whl", hash = "sha256:8c764f62f4b1d89d1ef264853b6dd9fee14a89e9b86a81bc2157fe3531425eb4"}, + {file = "google_re2-1.1-4-cp312-cp312-win_amd64.whl", hash = "sha256:0be2666df4bc5381a5d693585f9bbfefb0bfd3c07530d7e403f181f5de47254a"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:5cb1b63a0bfd8dd65d39d2f3b2e5ae0a06ce4b2ce5818a1d1fc78a786a252673"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:e41751ce6b67a95230edd0772226dc94c2952a2909674cd69df9804ed0125307"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_13_0_arm64.whl", hash = "sha256:b998cfa2d50bf4c063e777c999a7e8645ec7e5d7baf43ad71b1e2e10bb0300c3"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_13_0_x86_64.whl", hash = "sha256:226ca3b0c2e970f3fc82001ac89e845ecc7a4bb7c68583e7a76cda70b61251a7"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:9adec1f734ebad7c72e56c85f205a281d8fe9bf6583bc21020157d3f2812ce89"}, + {file = "google_re2-1.1-4-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:9c34f3c64ba566af967d29e11299560e6fdfacd8ca695120a7062b6ed993b179"}, + {file = "google_re2-1.1-4-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b85385fe293838e0d0b6e19e6c48ba8c6f739ea92ce2e23b718afe7b343363"}, + {file = "google_re2-1.1-4-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4694daa8a8987cfb568847aa872f9990e930c91a68c892ead876411d4b9012c3"}, + {file = "google_re2-1.1-4-cp38-cp38-win32.whl", hash = "sha256:5e671e9be1668187e2995aac378de574fa40df70bb6f04657af4d30a79274ce0"}, + {file = "google_re2-1.1-4-cp38-cp38-win_amd64.whl", hash = "sha256:f66c164d6049a8299f6dfcfa52d1580576b4b9724d6fcdad2f36f8f5da9304b6"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:25cb17ae0993a48c70596f3a3ef5d659638106401cc8193f51c0d7961b3b3eb7"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:5f101f86d14ca94ca4dcf63cceaa73d351f2be2481fcaa29d9e68eeab0dc2a88"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_13_0_arm64.whl", hash = "sha256:4e82591e85bf262a6d74cff152867e05fc97867c68ba81d6836ff8b0e7e62365"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:1f61c09b93ffd34b1e2557e5a9565039f935407a5786dbad46f64f1a484166e6"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:12b390ad8c7e74bab068732f774e75e0680dade6469b249a721f3432f90edfc3"}, + {file = "google_re2-1.1-4-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:1284343eb31c2e82ed2d8159f33ba6842238a56782c881b07845a6d85613b055"}, + {file = "google_re2-1.1-4-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c7b38e0daf2c06e4d3163f4c732ab3ad2521aecfed6605b69e4482c612da303"}, + {file = "google_re2-1.1-4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4d4f0823e8b2f6952a145295b1ff25245ce9bb136aff6fe86452e507d4c1dd"}, + {file = "google_re2-1.1-4-cp39-cp39-win32.whl", hash = "sha256:1afae56b2a07bb48cfcfefaa15ed85bae26a68f5dc7f9e128e6e6ea36914e847"}, + {file = "google_re2-1.1-4-cp39-cp39-win_amd64.whl", hash = "sha256:aa7d6d05911ab9c8adbf3c225a7a120ab50fd2784ac48f2f0d140c0b7afc2b55"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:222fc2ee0e40522de0b21ad3bc90ab8983be3bf3cec3d349c80d76c8bb1a4beb"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d4763b0b9195b72132a4e7de8e5a9bf1f05542f442a9115aa27cfc2a8004f581"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:209649da10c9d4a93d8a4d100ecbf9cc3b0252169426bec3e8b4ad7e57d600cf"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:68813aa333c1604a2df4a495b2a6ed065d7c8aebf26cc7e7abb5a6835d08353c"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:370a23ec775ad14e9d1e71474d56f381224dcf3e72b15d8ca7b4ad7dd9cd5853"}, + {file = "google_re2-1.1-5-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:14664a66a3ddf6bc9e56f401bf029db2d169982c53eff3f5876399104df0e9a6"}, + {file = "google_re2-1.1-5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea3722cc4932cbcebd553b69dce1b4a73572823cff4e6a244f1c855da21d511"}, + {file = "google_re2-1.1-5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e14bb264c40fd7c627ef5678e295370cd6ba95ca71d835798b6e37502fc4c690"}, + {file = "google_re2-1.1-5-cp310-cp310-win32.whl", hash = "sha256:39512cd0151ea4b3969c992579c79b423018b464624ae955be685fc07d94556c"}, + {file = "google_re2-1.1-5-cp310-cp310-win_amd64.whl", hash = "sha256:ac66537aa3bc5504320d922b73156909e3c2b6da19739c866502f7827b3f9fdf"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b5ea68d54890c9edb1b930dcb2658819354e5d3f2201f811798bbc0a142c2b4"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:33443511b6b83c35242370908efe2e8e1e7cae749c766b2b247bf30e8616066c"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:413d77bdd5ba0bfcada428b4c146e87707452ec50a4091ec8e8ba1413d7e0619"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:5171686e43304996a34baa2abcee6f28b169806d0e583c16d55e5656b092a414"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b284db130283771558e31a02d8eb8fb756156ab98ce80035ae2e9e3a5f307c4"}, + {file = "google_re2-1.1-5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:296e6aed0b169648dc4b870ff47bd34c702a32600adb9926154569ef51033f47"}, + {file = "google_re2-1.1-5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38d50e68ead374160b1e656bbb5d101f0b95fb4cc57f4a5c12100155001480c5"}, + {file = "google_re2-1.1-5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a0416a35921e5041758948bcb882456916f22845f66a93bc25070ef7262b72a"}, + {file = "google_re2-1.1-5-cp311-cp311-win32.whl", hash = "sha256:a1d59568bbb5de5dd56dd6cdc79907db26cce63eb4429260300c65f43469e3e7"}, + {file = "google_re2-1.1-5-cp311-cp311-win_amd64.whl", hash = "sha256:72f5a2f179648b8358737b2b493549370debd7d389884a54d331619b285514e3"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:cbc72c45937b1dc5acac3560eb1720007dccca7c9879138ff874c7f6baf96005"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5fadd1417fbef7235fa9453dba4eb102e6e7d94b1e4c99d5fa3dd4e288d0d2ae"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:040f85c63cc02696485b59b187a5ef044abe2f99b92b4fb399de40b7d2904ccc"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:64e3b975ee6d9bbb2420494e41f929c1a0de4bcc16d86619ab7a87f6ea80d6bd"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8ee370413e00f4d828eaed0e83b8af84d7a72e8ee4f4bd5d3078bc741dfc430a"}, + {file = "google_re2-1.1-5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:5b89383001079323f693ba592d7aad789d7a02e75adb5d3368d92b300f5963fd"}, + {file = "google_re2-1.1-5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cb4fdfbbda16ae31b41a6388ea621510db82feb8217a74bf36552ecfcd50ad"}, + {file = "google_re2-1.1-5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ebedd84ae8be10b7a71a16162376fd67a2386fe6361ef88c622dcf7fd679daf"}, + {file = "google_re2-1.1-5-cp312-cp312-win32.whl", hash = "sha256:c8e22d1692bc2c81173330c721aff53e47ffd3c4403ff0cd9d91adfd255dd150"}, + {file = "google_re2-1.1-5-cp312-cp312-win_amd64.whl", hash = "sha256:5197a6af438bb8c4abda0bbe9c4fbd6c27c159855b211098b29d51b73e4cbcf6"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:b6727e0b98417e114b92688ad2aa256102ece51f29b743db3d831df53faf1ce3"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:711e2b6417eb579c61a4951029d844f6b95b9b373b213232efd413659889a363"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_13_0_arm64.whl", hash = "sha256:71ae8b3df22c5c154c8af0f0e99d234a450ef1644393bc2d7f53fc8c0a1e111c"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_13_0_x86_64.whl", hash = "sha256:94a04e214bc521a3807c217d50cf099bbdd0c0a80d2d996c0741dbb995b5f49f"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:a770f75358508a9110c81a1257721f70c15d9bb592a2fb5c25ecbd13566e52a5"}, + {file = "google_re2-1.1-5-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:07c9133357f7e0b17c6694d5dcb82e0371f695d7c25faef2ff8117ef375343ff"}, + {file = "google_re2-1.1-5-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:204ca6b1cf2021548f4a9c29ac015e0a4ab0a7b6582bf2183d838132b60c8fda"}, + {file = "google_re2-1.1-5-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b95857c2c654f419ca684ec38c9c3325c24e6ba7d11910a5110775a557bb18"}, + {file = "google_re2-1.1-5-cp38-cp38-win32.whl", hash = "sha256:347ac770e091a0364e822220f8d26ab53e6fdcdeaec635052000845c5a3fb869"}, + {file = "google_re2-1.1-5-cp38-cp38-win_amd64.whl", hash = "sha256:ec32bb6de7ffb112a07d210cf9f797b7600645c2d5910703fa07f456dd2150e0"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:eb5adf89060f81c5ff26c28e261e6b4997530a923a6093c9726b8dec02a9a326"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a22630c9dd9ceb41ca4316bccba2643a8b1d5c198f21c00ed5b50a94313aaf10"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_13_0_arm64.whl", hash = "sha256:544dc17fcc2d43ec05f317366375796351dec44058e1164e03c3f7d050284d58"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:19710af5ea88751c7768575b23765ce0dfef7324d2539de576f75cdc319d6654"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:f82995a205e08ad896f4bd5ce4847c834fab877e1772a44e5f262a647d8a1dec"}, + {file = "google_re2-1.1-5-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:63533c4d58da9dc4bc040250f1f52b089911699f0368e0e6e15f996387a984ed"}, + {file = "google_re2-1.1-5-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79e00fcf0cb04ea35a22b9014712d448725ce4ddc9f08cc818322566176ca4b0"}, + {file = "google_re2-1.1-5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc41afcefee2da6c4ed883a93d7f527c4b960cd1d26bbb0020a7b8c2d341a60a"}, + {file = "google_re2-1.1-5-cp39-cp39-win32.whl", hash = "sha256:486730b5e1f1c31b0abc6d80abe174ce4f1188fe17d1b50698f2bf79dc6e44be"}, + {file = "google_re2-1.1-5-cp39-cp39-win_amd64.whl", hash = "sha256:4de637ca328f1d23209e80967d1b987d6b352cd01b3a52a84b4d742c69c3da6c"}, ] [[package]] @@ -4223,10 +4431,13 @@ files = [ {file = "lxml-4.9.3-cp27-cp27m-macosx_11_0_x86_64.whl", hash = "sha256:b0a545b46b526d418eb91754565ba5b63b1c0b12f9bd2f808c852d9b4b2f9b5c"}, {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:075b731ddd9e7f68ad24c635374211376aa05a281673ede86cbe1d1b3455279d"}, {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e224d5755dba2f4a9498e150c43792392ac9b5380aa1b845f98a1618c94eeef"}, + {file = "lxml-4.9.3-cp27-cp27m-win32.whl", hash = "sha256:2c74524e179f2ad6d2a4f7caf70e2d96639c0954c943ad601a9e146c76408ed7"}, + {file = "lxml-4.9.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4f1026bc732b6a7f96369f7bfe1a4f2290fb34dce00d8644bc3036fb351a4ca1"}, {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0781a98ff5e6586926293e59480b64ddd46282953203c76ae15dbbbf302e8bb"}, {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cef2502e7e8a96fe5ad686d60b49e1ab03e438bd9123987994528febd569868e"}, {file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"}, {file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"}, + {file = "lxml-4.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae8b9c6deb1e634ba4f1930eb67ef6e6bf6a44b6eb5ad605642b2d6d5ed9ce3c"}, {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"}, {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"}, {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"}, @@ -4235,6 +4446,7 @@ files = [ {file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"}, {file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"}, {file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"}, + {file = "lxml-4.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9767e79108424fb6c3edf8f81e6730666a50feb01a328f4a016464a5893f835a"}, {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"}, {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"}, {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"}, @@ -4254,6 +4466,7 @@ files = [ {file = "lxml-4.9.3-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:64f479d719dc9f4c813ad9bb6b28f8390360660b73b2e4beb4cb0ae7104f1c12"}, {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:dd708cf4ee4408cf46a48b108fb9427bfa00b9b85812a9262b5c668af2533ea5"}, {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c31c7462abdf8f2ac0577d9f05279727e698f97ecbb02f17939ea99ae8daa98"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e3cd95e10c2610c360154afdc2f1480aea394f4a4f1ea0a5eacce49640c9b190"}, {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:4930be26af26ac545c3dffb662521d4e6268352866956672231887d18f0eaab2"}, {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4aec80cde9197340bc353d2768e2a75f5f60bacda2bab72ab1dc499589b3878c"}, {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14e019fd83b831b2e61baed40cab76222139926b1fb5ed0e79225bc0cae14584"}, @@ -4263,6 +4476,7 @@ files = [ {file = "lxml-4.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bef4e656f7d98aaa3486d2627e7d2df1157d7e88e7efd43a65aa5dd4714916cf"}, {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:46f409a2d60f634fe550f7133ed30ad5321ae2e6630f13657fb9479506b00601"}, {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4c28a9144688aef80d6ea666c809b4b0e50010a2aca784c97f5e6bf143d9f129"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:141f1d1a9b663c679dc524af3ea1773e618907e96075262726c7612c02b149a4"}, {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:53ace1c1fd5a74ef662f844a0413446c0629d151055340e9893da958a374f70d"}, {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17a753023436a18e27dd7769e798ce302963c236bc4114ceee5b25c18c52c693"}, {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d298a1bd60c067ea75d9f684f5f3992c9d6766fadbc0bcedd39750bf344c2f4"}, @@ -4272,6 +4486,7 @@ files = [ {file = "lxml-4.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:120fa9349a24c7043854c53cae8cec227e1f79195a7493e09e0c12e29f918e52"}, {file = "lxml-4.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4d2d1edbca80b510443f51afd8496be95529db04a509bc8faee49c7b0fb6d2cc"}, {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d7e43bd40f65f7d97ad8ef5c9b1778943d02f04febef12def25f7583d19baac"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:71d66ee82e7417828af6ecd7db817913cb0cf9d4e61aa0ac1fde0583d84358db"}, {file = "lxml-4.9.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6fc3c450eaa0b56f815c7b62f2b7fba7266c4779adcf1cece9e6deb1de7305ce"}, {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65299ea57d82fb91c7f019300d24050c4ddeb7c5a190e076b5f48a2b43d19c42"}, {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eadfbbbfb41b44034a4c757fd5d70baccd43296fb894dba0295606a7cf3124aa"}, @@ -4281,6 +4496,7 @@ files = [ {file = "lxml-4.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:92af161ecbdb2883c4593d5ed4815ea71b31fafd7fd05789b23100d081ecac96"}, {file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"}, {file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fbf521479bcac1e25a663df882c46a641a9bff6b56dc8b0fafaebd2f66fb231b"}, {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"}, {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"}, {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"}, @@ -4291,13 +4507,16 @@ files = [ {file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"}, {file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"}, {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"}, + {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:05186a0f1346ae12553d66df1cfce6f251589fea3ad3da4f3ef4e34b2d58c6a3"}, {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"}, {file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"}, {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"}, + {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:50670615eaf97227d5dc60de2dc99fb134a7130d310d783314e7724bf163f75d"}, {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"}, {file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"}, {file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"}, {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"}, + {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4da8ca0c0c0aea88fd46be8e44bd49716772358d648cce45fe387f7b92374a7"}, {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"}, {file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"}, {file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"}, @@ -6434,6 +6653,7 @@ files = [ {file = "pymongo-4.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab6bcc8e424e07c1d4ba6df96f7fb963bcb48f590b9456de9ebd03b88084fe8"}, {file = "pymongo-4.6.0-cp312-cp312-win32.whl", hash = "sha256:47aa128be2e66abd9d1a9b0437c62499d812d291f17b55185cb4aa33a5f710a4"}, {file = "pymongo-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:014e7049dd019a6663747ca7dae328943e14f7261f7c1381045dfc26a04fa330"}, + {file = "pymongo-4.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e24025625bad66895b1bc3ae1647f48f0a92dd014108fb1be404c77f0b69ca67"}, {file = "pymongo-4.6.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:288c21ab9531b037f7efa4e467b33176bc73a0c27223c141b822ab4a0e66ff2a"}, {file = "pymongo-4.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:747c84f4e690fbe6999c90ac97246c95d31460d890510e4a3fa61b7d2b87aa34"}, {file = "pymongo-4.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:055f5c266e2767a88bb585d01137d9c7f778b0195d3dbf4a487ef0638be9b651"}, @@ -6707,6 +6927,23 @@ files = [ py = "*" pytest = ">=3.10" +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-order" version = "1.1.0" @@ -6870,7 +7107,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -8854,4 +9090,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<3.13" -content-hash = "1a0cf7f14a3c3846981cb5fdddd2a17b8f9ba2024ad6f6f8f3413dc40497122e" +content-hash = "b8808caf87b2a80d4fa977696580588f5057c7ab384d3439abca5a444e1f6e41" diff --git a/pyproject.toml b/pyproject.toml index ea6e8b7145..9216571613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,7 @@ types-sqlalchemy = "^1.4.53.38" types-pytz = ">=2024.1.0.20240203" ruff = "^0.3.2" pyjwt = "^2.8.0" +pytest-mock = "^3.14.0" [tool.poetry.group.pipeline] optional=true diff --git a/tests/destinations/test_path_utils.py b/tests/destinations/test_path_utils.py index 1cf2b17d76..7ae7198492 100644 --- a/tests/destinations/test_path_utils.py +++ b/tests/destinations/test_path_utils.py @@ -1,46 +1,325 @@ +from typing import List, Tuple + import pytest +import dlt.destinations.path_utils + +from dlt.common import logger, pendulum +from dlt.common.storages import LoadStorage +from dlt.common.storages.load_package import ParsedLoadJobFileName + +from dlt.destinations.path_utils import create_path, get_table_prefix_layout -from dlt.destinations import path_utils from dlt.destinations.exceptions import InvalidFilesystemLayout, CantExtractTablePrefix +from tests.common.storages.utils import start_loading_file, load_storage -def test_layout_validity() -> None: - path_utils.check_layout("{table_name}") - path_utils.check_layout("{schema_name}/{table_name}/{load_id}.{file_id}.{ext}") - with pytest.raises(InvalidFilesystemLayout) as exc: - path_utils.check_layout("{other_ph}.{table_name}") - assert exc.value.invalid_placeholders == ["other_ph"] +TestLoad = Tuple[str, ParsedLoadJobFileName] -def test_create_path() -> None: - path_vars = { - "schema_name": "schema_name", - "table_name": "table_name", - "load_id": "load_id", - "file_id": "file_id", - "ext": "ext", - } - path = path_utils.create_path( - "{schema_name}/{table_name}/{load_id}.{file_id}.{ext}", **path_vars +def dummy_callback(*args, **kwargs): + assert len(args) == 5 + return "-".join(args) + + +def dummy_callback2(*args, **kwargs): + assert len(args) == 5 + return "random-value" + + +EXTRA_PLACEHOLDERS = { + "type": "one-for-all", + "vm": "beam", + "module": "__MODULE__", + "bobo": "is-name", + "callback": dummy_callback, + "random_value": dummy_callback2, +} + +frozen_datetime = pendulum.DateTime( + year=2024, + month=4, + day=14, + hour=8, + minute=32, + second=0, + microsecond=0, +) + +# Each layout example is a tuple of +# 1. layout to expand, +# 2. expected path, +# 3. valid or not flag, +# 4. the list of expected invalid or unknown placeholders. +ALL_LAYOUTS = ( # type: ignore + # Usual placeholders + ( + "{schema_name}/{table_name}/{load_id}.{file_id}.{ext}", + "schema-name/mocked-table/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{schema_name}.{table_name}.{load_id}.{file_id}.{ext}", + "schema-name.mocked-table.mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}88{load_id}-u-{file_id}.{ext}", + "mocked-table88mocked-load-id-u-mocked-file-id.jsonl", + True, + [], + ), + # Additional placeholders + ( + "{table_name}/{curr_date}/{load_id}.{file_id}.{ext}{timestamp}", + f"mocked-table/{str(frozen_datetime.date())}/mocked-load-id.mocked-file-id.jsonl{int(frozen_datetime.timestamp())}", + True, + [], + ), + ( + "{table_name}/{YYYY}-{MM}-{DD}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('YYYY-MM-DD')}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{YYYY}-{MMM}-{D}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('YYYY-MMM-D').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{DD}/{HH}/{m}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('DD/HH/m').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{D}/{HH}/{mm}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('D/HH/mm').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{timestamp}/{load_id}.{file_id}.{ext}", + f"mocked-table/{int(frozen_datetime.timestamp())}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/dayofweek-{ddd}/{load_id}.{file_id}.{ext}", + f"mocked-table/dayofweek-{frozen_datetime.format('ddd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{ddd}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('ddd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{Q}/{MM}/{ddd}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('Q/MM/ddd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{YY}-{MM}-{D}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('YY-MM-D').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{Y}-{M}-{D}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('Y-M-D').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{HH}/{mm}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('HH/mm').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{H}/{m}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('H/m').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{M}/{dddd}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('M/dddd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{M}/{ddd}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('M/ddd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{M}/{dd}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('M/dd').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{M}/{d}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('M/d').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{load_package_timestamp}/{d}/{load_id}.{file_id}.{ext}", + f"mocked-table/{int(frozen_datetime.timestamp())}/{frozen_datetime.format('d')}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + # Extra placehodlers + ( + "{table_name}/{timestamp}/{type}-{vm}-{module}/{load_id}.{file_id}.{ext}", + f"mocked-table/{int(frozen_datetime.timestamp())}/one-for-all-beam-__MODULE__/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{timestamp}/{type}-{bobo}-{module}/{load_id}.{file_id}.{ext}", + f"mocked-table/{int(frozen_datetime.timestamp())}/one-for-all-is-name-__MODULE__/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{type}/{bobo}/{module}/{load_id}.{file_id}.{ext}", + "mocked-table/one-for-all/is-name/__MODULE__/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{callback}/{callback}/{type}/{load_id}.{file_id}.{ext}", + "mocked-table/schema-name-mocked-table-mocked-load-id-mocked-file-id-jsonl/schema-name-mocked-table-mocked-load-id-mocked-file-id-jsonl/one-for-all/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{random_value}/{callback}/{load_id}.{file_id}.{ext}", + "mocked-table/random-value/schema-name-mocked-table-mocked-load-id-mocked-file-id-jsonl/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ("{timestamp}{table_name}", f"{int(frozen_datetime.timestamp())}mocked-table", True, []), + ("{ddd}/{MMM}/{table_name}", "sun/apr/mocked-table", True, []), + ( + "{Y}/{timestamp}/{table_name}", + f"{frozen_datetime.format('YYYY')}/{int(frozen_datetime.timestamp())}/mocked-table", + True, + [], + ), + ("{load_id}/{ext}/{table_name}", "mocked-load-id/jsonl/mocked-table", True, []), + ("{HH}/{mm}/{schema_name}", f"{frozen_datetime.format('HH/mm')}/schema-name", True, []), + ("{type}/{bobo}/{table_name}", "one-for-all/is-name/mocked-table", True, []), + # invalid placeholders + ("{illegal_placeholder}{table_name}", "", False, ["illegal_placeholder"]), + ("{unknown_placeholder}/{volume}/{table_name}", "", False, ["unknown_placeholder", "volume"]), + ( + "{table_name}/{abc}/{load_id}.{ext}{timestamp}-{random}", + "", + False, + ["abc", "random"], + ), +) + + +@pytest.fixture +def test_load(load_storage: LoadStorage) -> TestLoad: + load_id, filename = start_loading_file(load_storage, "test file") # type: ignore[arg-type] + info = ParsedLoadJobFileName.parse(filename) + return load_id, info + + +@pytest.mark.parametrize("layout,expected_path,is_valid,invalid_placeholders", ALL_LAYOUTS) +def test_layout_validity( + layout: str, + expected_path: str, + is_valid: bool, + invalid_placeholders: List[str], + mocker, +) -> None: + job_info = ParsedLoadJobFileName( + table_name="mocked-table", + file_id="mocked-file-id", + retry_count=0, + file_format="jsonl", + ) + load_id = "mocked-load-id" + mocker.patch("pendulum.now", return_value=frozen_datetime) + mocker.patch( + "pendulum.DateTime.timestamp", + return_value=frozen_datetime.timestamp(), + ) + + mocker.patch( + "dlt.common.storages.load_package.ParsedLoadJobFileName.parse", + return_value=job_info, + ) + + now_timestamp = frozen_datetime.to_iso8601_string() + if is_valid: + path = create_path( + layout, + schema_name="schema-name", + load_id=load_id, + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + extra_placeholders=EXTRA_PLACEHOLDERS, + ) + assert path == expected_path + assert len(path.split("/")) == len(layout.split("/")) + else: + with pytest.raises(InvalidFilesystemLayout) as exc: + create_path( + layout, + schema_name="schema-name", + load_id=load_id, + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + extra_placeholders=EXTRA_PLACEHOLDERS, + ) + assert set(exc.value.invalid_placeholders) == set(invalid_placeholders) + + +def test_create_path(test_load: TestLoad) -> None: + load_id, job_info = test_load + path = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{ext}", + schema_name="schema_name", + load_id=load_id, + file_name=job_info.file_name(), ) - assert path == "schema_name/table_name/load_id.file_id.ext" + assert path == f"schema_name/mock_table/{load_id}.{job_info.file_id}.{job_info.file_format}" # extension gets added automatically - path = path_utils.create_path("{schema_name}/{table_name}/{load_id}", **path_vars) - assert path == "schema_name/table_name/load_id.ext" + path = create_path( + "{schema_name}/{table_name}/{load_id}.{ext}", + schema_name="schema_name", + load_id=load_id, + file_name=job_info.file_name(), + ) + assert path == f"schema_name/mock_table/{load_id}.{job_info.file_format}" def test_get_table_prefix_layout() -> None: - prefix_layout = path_utils.get_table_prefix_layout( - "{schema_name}/{table_name}/{load_id}.{file_id}.{ext}" - ) + prefix_layout = get_table_prefix_layout("{schema_name}/{table_name}/{load_id}.{file_id}.{ext}") assert prefix_layout == "{schema_name}/{table_name}/" assert ( prefix_layout.format(schema_name="my_schema", table_name="my_table") == "my_schema/my_table/" ) - prefix_layout = path_utils.get_table_prefix_layout( + prefix_layout = get_table_prefix_layout( "some_random{schema_name}/stuff_in_between/{table_name}/{load_id}" ) assert prefix_layout == "some_random{schema_name}/stuff_in_between/{table_name}/" @@ -51,19 +330,196 @@ def test_get_table_prefix_layout() -> None: # disallow missing table_name with pytest.raises(CantExtractTablePrefix): - path_utils.get_table_prefix_layout("some_random{schema_name}/stuff_in_between/") + get_table_prefix_layout("some_random{schema_name}/stuff_in_between/") # disallow other params before table_name with pytest.raises(CantExtractTablePrefix): - path_utils.get_table_prefix_layout("{file_id}some_random{table_name}/stuff_in_between/") + get_table_prefix_layout("{file_id}some_random{table_name}/stuff_in_between/") # disallow any placeholders before table name (ie. Athena) with pytest.raises(CantExtractTablePrefix): - path_utils.get_table_prefix_layout( + get_table_prefix_layout( "{schema_name}some_random{table_name}/stuff_in_between/", supported_prefix_placeholders=[], ) # disallow table_name without following separator with pytest.raises(CantExtractTablePrefix): - path_utils.get_table_prefix_layout("{schema_name}/{table_name}{load_id}.{file_id}.{ext}") + get_table_prefix_layout("{schema_name}/{table_name}{load_id}.{file_id}.{ext}") + + +def test_create_path_uses_provided_load_package_timestamp(test_load: TestLoad) -> None: + load_id, job_info = test_load + now = pendulum.now() + timestamp = str(int(now.timestamp())) + path = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + load_package_timestamp=now.to_iso8601_string(), + file_name=job_info.file_name(), + ) + + assert timestamp in path + assert path.endswith(f"{timestamp}.{job_info.file_format}") + + +def test_create_path_resolves_current_datetime(test_load: TestLoad) -> None: + """Check the flow when the current_datetime is passed + + Happy path checks + + 1. Callback, + 2. pendulum.DateTime + + Failures when neither callback nor the value is not of pendulum.DateTime + """ + load_id, job_info = test_load + now = pendulum.now() + timestamp = int(now.timestamp()) + now_timestamp = now.to_iso8601_string() + calls = 0 + + def current_datetime_callback(): + nonlocal calls + calls += 1 + return now + + created_path_1 = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + current_datetime=current_datetime_callback, + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + ) + + created_path_2 = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + current_datetime=now, + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + ) + + assert created_path_1 == created_path_2 + assert ( + created_path_1 == f"schema_name/mock_table/{load_id}.{job_info.file_id}.{timestamp}.jsonl" + ) + assert ( + created_path_2 == f"schema_name/mock_table/{load_id}.{job_info.file_id}.{timestamp}.jsonl" + ) + + # expect only one call + assert calls == 1 + + # If the value for current_datetime is not pendulum.DateTime + # it should fail with RuntimeError exception + with pytest.raises(AttributeError): + create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + current_datetime="now", # type: ignore + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + ) + with pytest.raises(RuntimeError): + create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + current_datetime=lambda: 1234, # type: ignore + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + ) + + +def test_create_path_uses_current_moment_if_current_datetime_is_not_given( + test_load: TestLoad, mocker +) -> None: + load_id, job_info = test_load + logger_spy = mocker.spy(logger, "info") + pendulum_spy = mocker.spy(pendulum, "now") + path = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + file_name=job_info.file_name(), + ) + timestamp = int(pendulum_spy.spy_return.timestamp()) + assert path == f"schema_name/mock_table/{load_id}.{job_info.file_id}.{timestamp}.jsonl" + logger_spy.assert_called_once_with("current_datetime is not set, using pendulum.now()") + pendulum_spy.assert_called() + + +def test_create_path_uses_load_package_timestamp_as_current_datetime( + test_load: TestLoad, mocker +) -> None: + load_id, job_info = test_load + now = pendulum.now() + timestamp = int(now.timestamp()) + now_timestamp = now.to_iso8601_string() + logger_spy = mocker.spy(logger, "info") + ensure_pendulum_datetime_spy = mocker.spy( + dlt.destinations.path_utils, "ensure_pendulum_datetime" + ) + path = create_path( + "{schema_name}/{table_name}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + load_package_timestamp=now_timestamp, + file_name=job_info.file_name(), + ) + assert path == f"schema_name/mock_table/{load_id}.{job_info.file_id}.{timestamp}.jsonl" + logger_spy.assert_called_once_with( + "current_datetime is not set, using timestamp from load package" + ) + ensure_pendulum_datetime_spy.assert_called_once_with(now_timestamp) + + +def test_create_path_resolves_extra_placeholders(test_load: TestLoad) -> None: + load_id, job_info = test_load + + class Counter: + def __init__(self) -> None: + self.count = 0 + + def inc(self, *args, **kwargs): + schema_name, table_name, passed_load_id, file_id, ext = args + assert len(args) == 5 + assert schema_name == "schema_name" + assert table_name == "mock_table" + assert passed_load_id == load_id + assert file_id == job_info.file_id + assert ext == job_info.file_format + self.count += 1 + return "boo" + + counter = Counter() + extra_placeholders = { + "value": 1, + "callable_1": counter.inc, + "otter": counter.inc, + "otters": "lab", + "dlt": "labs", + "dlthub": "platform", + "x": "files", + } + now = pendulum.now() + timestamp = int(now.timestamp()) + now_timestamp = now.to_iso8601_string() + created_path = create_path( + "{schema_name}/{table_name}/{callable_1}-{otter}/{load_id}.{file_id}.{timestamp}.{ext}", + schema_name="schema_name", + load_id=load_id, + extra_placeholders=extra_placeholders, + file_name=job_info.file_name(), + load_package_timestamp=now_timestamp, + ) + assert ( + created_path + == f"schema_name/mock_table/boo-boo/{load_id}.{job_info.file_id}.{timestamp}.jsonl" + ) + assert counter.count == 2 diff --git a/tests/load/filesystem/test_filesystem_client.py b/tests/load/filesystem/test_filesystem_client.py index 5d6dbe33ef..5d2404ff48 100644 --- a/tests/load/filesystem/test_filesystem_client.py +++ b/tests/load/filesystem/test_filesystem_client.py @@ -1,5 +1,6 @@ import posixpath import os +from unittest import mock import pytest @@ -7,13 +8,12 @@ from dlt.common.storages import FileStorage, ParsedLoadJobFileName from dlt.destinations.impl.filesystem.filesystem import ( - LoadFilesystemJob, FilesystemDestinationClientConfiguration, ) +from dlt.destinations.path_utils import create_path, prepare_datetime_params from tests.load.filesystem.utils import perform_load from tests.utils import clean_test_storage, init_test_logging -from tests.utils import preserve_environ, autouse_test_storage # mark all tests as essential, do not remove pytestmark = pytest.mark.essential @@ -59,9 +59,15 @@ def test_successful_load(write_disposition: str, layout: str, with_gdrive_bucket os.environ.pop("DESTINATION__FILESYSTEM__LAYOUT", None) dataset_name = "test_" + uniq_id() - - with perform_load( - dataset_name, NORMALIZED_FILES, write_disposition=write_disposition + timestamp = "2024-04-05T09:16:59.942779Z" + mocked_timestamp = {"state": {"created_at": timestamp}} + with mock.patch( + "dlt.current.load_package", + return_value=mocked_timestamp, + ), perform_load( + dataset_name, + NORMALIZED_FILES, + write_disposition=write_disposition, ) as load_info: client, jobs, _, load_id = load_info layout = client.config.layout @@ -83,6 +89,7 @@ def test_successful_load(write_disposition: str, layout: str, with_gdrive_bucket load_id=load_id, file_id=job_info.file_id, ext=job_info.file_format, + **prepare_datetime_params(load_package_timestamp=timestamp), ), ) @@ -96,17 +103,31 @@ def test_replace_write_disposition(layout: str, default_buckets_env: str) -> Non os.environ["DESTINATION__FILESYSTEM__LAYOUT"] = layout else: os.environ.pop("DESTINATION__FILESYSTEM__LAYOUT", None) + dataset_name = "test_" + uniq_id() # NOTE: context manager will delete the dataset at the end so keep it open until the end - with perform_load(dataset_name, NORMALIZED_FILES, write_disposition="replace") as load_info: + timestamp = "2024-04-05T09:16:59.942779Z" + mocked_timestamp = {"state": {"created_at": timestamp}} + with mock.patch( + "dlt.current.load_package", + return_value=mocked_timestamp, + ), perform_load( + dataset_name, + NORMALIZED_FILES, + write_disposition="replace", + ) as load_info: client, _, root_path, load_id1 = load_info layout = client.config.layout - # this path will be kept after replace job_2_load_1_path = posixpath.join( root_path, - LoadFilesystemJob.make_destination_filename( - layout, NORMALIZED_FILES[1], client.schema.name, load_id1 + create_path( + layout, + NORMALIZED_FILES[1], + client.schema.name, + load_id1, + load_package_timestamp=timestamp, + extra_placeholders=client.config.extra_placeholders, ), ) @@ -118,8 +139,13 @@ def test_replace_write_disposition(layout: str, default_buckets_env: str) -> Non # this one we expect to be replaced with job_1_load_2_path = posixpath.join( root_path, - LoadFilesystemJob.make_destination_filename( - layout, NORMALIZED_FILES[0], client.schema.name, load_id2 + create_path( + layout, + NORMALIZED_FILES[0], + client.schema.name, + load_id2, + load_package_timestamp=timestamp, + extra_placeholders=client.config.extra_placeholders, ), ) @@ -131,6 +157,7 @@ def test_replace_write_disposition(layout: str, default_buckets_env: str) -> Non ): for f in files: paths.append(posixpath.join(basedir, f)) + ls = set(paths) assert ls == {job_2_load_1_path, job_1_load_2_path} @@ -144,19 +171,39 @@ def test_append_write_disposition(layout: str, default_buckets_env: str) -> None os.environ.pop("DESTINATION__FILESYSTEM__LAYOUT", None) dataset_name = "test_" + uniq_id() # NOTE: context manager will delete the dataset at the end so keep it open until the end - with perform_load(dataset_name, NORMALIZED_FILES, write_disposition="append") as load_info: + # also we would like to have reliable timestamp for this test so we patch it + timestamp = "2024-04-05T09:16:59.942779Z" + mocked_timestamp = {"state": {"created_at": timestamp}} + with mock.patch( + "dlt.current.load_package", + return_value=mocked_timestamp, + ), perform_load( + dataset_name, + NORMALIZED_FILES, + write_disposition="append", + ) as load_info: client, jobs1, root_path, load_id1 = load_info with perform_load(dataset_name, NORMALIZED_FILES, write_disposition="append") as load_info: client, jobs2, root_path, load_id2 = load_info layout = client.config.layout expected_files = [ - LoadFilesystemJob.make_destination_filename( - layout, job.file_name(), client.schema.name, load_id1 + create_path( + layout, + job.file_name(), + client.schema.name, + load_id1, + load_package_timestamp=timestamp, + extra_placeholders=client.config.extra_placeholders, ) for job in jobs1 ] + [ - LoadFilesystemJob.make_destination_filename( - layout, job.file_name(), client.schema.name, load_id2 + create_path( + layout, + job.file_name(), + client.schema.name, + load_id2, + load_package_timestamp=timestamp, + extra_placeholders=client.config.extra_placeholders, ) for job in jobs2 ] diff --git a/tests/load/filesystem/test_filesystem_common.py b/tests/load/filesystem/test_filesystem_common.py index e385d77a48..5cb064a3b2 100644 --- a/tests/load/filesystem/test_filesystem_common.py +++ b/tests/load/filesystem/test_filesystem_common.py @@ -1,5 +1,6 @@ import os import posixpath + from typing import Union, Dict from urllib.parse import urlparse @@ -7,12 +8,21 @@ from tenacity import retry, stop_after_attempt, wait_fixed -from dlt.common import pendulum +from dlt.common import logger +from dlt.common import json, pendulum +from dlt.common.configuration import resolve from dlt.common.configuration.inject import with_config -from dlt.common.configuration.specs import AzureCredentials, AzureCredentialsWithoutDefaults +from dlt.common.configuration.specs import ( + AzureCredentials, + AzureCredentialsWithoutDefaults, +) from dlt.common.storages import fsspec_from_config, FilesystemConfiguration from dlt.common.storages.fsspec_filesystem import MTIME_DISPATCH, glob_files -from dlt.common.utils import uniq_id +from dlt.common.utils import custom_environ, uniq_id +from dlt.destinations import filesystem +from dlt.destinations.impl.filesystem.configuration import ( + FilesystemDestinationClientConfiguration, +) from tests.common.storages.utils import assert_sample_files from tests.load.utils import ALL_FILESYSTEM_DRIVERS, AWS_BUCKET from tests.utils import preserve_environ, autouse_test_storage @@ -128,7 +138,9 @@ def test_filesystem_instance_from_s3_endpoint(environment: Dict[str, str]) -> No def test_filesystem_configuration_with_additional_arguments() -> None: config = FilesystemConfiguration( - bucket_url="az://root", kwargs={"use_ssl": True}, client_kwargs={"verify": "public.crt"} + bucket_url="az://root", + kwargs={"use_ssl": True}, + client_kwargs={"verify": "public.crt"}, ) assert dict(config) == { "read_only": False, @@ -176,3 +188,71 @@ def test_s3_wrong_client_certificate(default_buckets_env: str, self_signed_cert: with pytest.raises(SSLError, match="SSL: CERTIFICATE_VERIFY_FAILED"): print(filesystem.ls("", detail=False)) + + +def test_filesystem_destination_config_reports_unused_placeholders(mocker) -> None: + with custom_environ({"DATASET_NAME": "BOBO"}): + extra_placeholders = { + "value": 1, + "otters": "lab", + "dlt": "labs", + "dlthub": "platform", + "x": "files", + } + logger_spy = mocker.spy(logger, "info") + resolve.resolve_configuration( + FilesystemDestinationClientConfiguration( + bucket_url="file:///tmp/dirbobo", + layout="{schema_name}/{table_name}/{otters}-x-{x}/{load_id}.{file_id}.{timestamp}.{ext}", + extra_placeholders=extra_placeholders, # type: ignore + ) + ) + logger_spy.assert_called_once_with("Found unused layout placeholders: value, dlt, dlthub") + + +def test_filesystem_destination_passed_parameters_override_config_values() -> None: + config_current_datetime = "2024-04-11T00:00:00Z" + config_extra_placeholders = {"placeholder_x": "x", "placeholder_y": "y"} + with custom_environ( + { + "DESTINATION__FILESYSTEM__BUCKET_URL": "file:///tmp/dirbobo", + "DESTINATION__FILESYSTEM__CURRENT_DATETIME": config_current_datetime, + "DESTINATION__FILESYSTEM__EXTRA_PLACEHOLDERS": json.dumps(config_extra_placeholders), + } + ): + extra_placeholders = { + "new_value": 1, + "dlt": "labs", + "dlthub": "platform", + } + now = pendulum.now() + config_now = pendulum.parse(config_current_datetime) + + # Check with custom datetime and extra placeholders + # both should override config values + filesystem_destination = filesystem( + extra_placeholders=extra_placeholders, current_datetime=now + ) + filesystem_config = FilesystemDestinationClientConfiguration()._bind_dataset_name( + dataset_name="dummy_dataset" + ) + bound_config = filesystem_destination.configuration(filesystem_config) + assert bound_config.current_datetime == now + assert bound_config.extra_placeholders == extra_placeholders + + # Check only passing one parameter + filesystem_destination = filesystem(extra_placeholders=extra_placeholders) + filesystem_config = FilesystemDestinationClientConfiguration()._bind_dataset_name( + dataset_name="dummy_dataset" + ) + bound_config = filesystem_destination.configuration(filesystem_config) + assert bound_config.current_datetime == config_now + assert bound_config.extra_placeholders == extra_placeholders + + filesystem_destination = filesystem() + filesystem_config = FilesystemDestinationClientConfiguration()._bind_dataset_name( + dataset_name="dummy_dataset" + ) + bound_config = filesystem_destination.configuration(filesystem_config) + assert bound_config.current_datetime == config_now + assert bound_config.extra_placeholders == config_extra_placeholders diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index d4e8777d28..d24b799349 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -1,16 +1,25 @@ -import pytest import csv +import os import posixpath from pathlib import Path +from typing import Any, Callable + +import dlt +import pytest -import dlt, os +from dlt.common import pendulum +from dlt.common.storages.load_package import ParsedLoadJobFileName from dlt.common.utils import uniq_id from dlt.common.storages.load_storage import LoadJobInfo -from dlt.destinations.impl.filesystem.filesystem import FilesystemClient, LoadFilesystemJob +from dlt.destinations import filesystem +from dlt.destinations.impl.filesystem.filesystem import FilesystemClient from dlt.common.schema.typing import LOADS_TABLE_NAME from tests.cases import arrow_table_all_data_types +from tests.common.utils import load_json_case from tests.utils import ALL_TEST_DATA_ITEM_FORMATS, TestDataItemFormat, skip_if_not_active +from dlt.destinations.path_utils import create_path + skip_if_not_active("filesystem") @@ -21,9 +30,12 @@ def assert_file_matches( """Verify file contents of load job are identical to the corresponding file in destination""" local_path = Path(job.file_path) filename = local_path.name - - destination_fn = LoadFilesystemJob.make_destination_filename( - layout, filename, client.schema.name, load_id + destination_fn = create_path( + layout, + filename, + client.schema.name, + load_id, + extra_placeholders=client.config.extra_placeholders, ) destination_path = posixpath.join(client.dataset_path, destination_fn) @@ -172,3 +184,88 @@ def some_source(): with open(other_data_files[0], "rb") as f: table = pq.read_table(f) assert table.column("value").to_pylist() == [1, 2, 3, 4, 5] + + +TEST_LAYOUTS = ( + "{schema_name}/{table_name}/{load_id}.{file_id}.{ext}", + "{schema_name}.{table_name}.{load_id}.{file_id}.{ext}", + "{table_name}88{load_id}-u-{file_id}.{ext}", + "{table_name}/{curr_date}/{load_id}.{file_id}.{ext}{timestamp}", + "{table_name}/{YYYY}-{MM}-{DD}/{load_id}.{file_id}.{ext}", + "{table_name}/{YYYY}-{MMM}-{D}/{load_id}.{file_id}.{ext}", + "{table_name}/{DD}/{HH}/{m}/{load_id}.{file_id}.{ext}", + "{table_name}/{D}/{HH}/{mm}/{load_id}.{file_id}.{ext}", + "{table_name}/{timestamp}/{load_id}.{file_id}.{ext}", + "{table_name}/{load_package_timestamp}/{d}/{load_id}.{file_id}.{ext}", + ( + "{table_name}/{YYYY}/{YY}/{Y}/{MMMM}/{MMM}/{MM}/{M}/{DD}/{D}/" + "{HH}/{H}/{ddd}/{dd}/{d}/{Q}/{timestamp}/{curr_date}/{load_id}.{file_id}.{ext}" + ), +) + + +@pytest.mark.parametrize("layout", TEST_LAYOUTS) +def test_filesystem_destination_extended_layout_placeholders(layout: str) -> None: + data = load_json_case("simple_row") + call_count = 0 + + def counter(value: Any) -> Callable[..., Any]: + def count(*args, **kwargs) -> Any: + nonlocal call_count + call_count += 1 + return value + + return count + + extra_placeholders = { + "who": "marcin", + "action": "says", + "what": "no potato", + "func": counter("lifting"), + "woot": "woot-woot", + "hiphip": counter("Hurraaaa"), + } + now = pendulum.now() + os.environ["DESTINATION__FILESYSTEM__BUCKET_URL"] = "file://_storage" + pipeline = dlt.pipeline( + pipeline_name="test_extended_layouts", + destination=filesystem( + layout=layout, + extra_placeholders=extra_placeholders, + kwargs={"auto_mkdir": True}, + current_datetime=counter(now), + ), + ) + load_info = pipeline.run( + dlt.resource(data, name="simple_rows"), + write_disposition="append", + ) + client = pipeline.destination_client() + expected_files = set() + known_files = set() + for basedir, _dirs, files in client.fs_client.walk(client.dataset_path): # type: ignore[attr-defined] + for file in files: + if file.endswith("jsonl"): + expected_files.add(os.path.join(basedir, file)) + + for load_package in load_info.load_packages: + for load_info in load_package.jobs["completed_jobs"]: # type: ignore[assignment] + job_info = ParsedLoadJobFileName.parse(load_info.file_path) # type: ignore[attr-defined] + path = create_path( + layout, + file_name=job_info.file_name(), + schema_name="test_extended_layouts", + load_id=load_package.load_id, + current_datetime=now, + load_package_timestamp=load_info.created_at.to_iso8601_string(), # type: ignore[attr-defined] + extra_placeholders=extra_placeholders, + ) + full_path = os.path.join(client.dataset_path, path) # type: ignore[attr-defined] + assert os.path.exists(full_path) + if full_path.endswith("jsonl"): + known_files.add(full_path) + + assert expected_files == known_files + # 6 is because simple_row contains two rows + # and in this test scenario we have 3 callbacks + assert call_count >= 6 diff --git a/tests/load/test_dummy_client.py b/tests/load/test_dummy_client.py index b25a643624..30de51f069 100644 --- a/tests/load/test_dummy_client.py +++ b/tests/load/test_dummy_client.py @@ -1,6 +1,7 @@ import os from concurrent.futures import ThreadPoolExecutor from time import sleep +from unittest import mock import pytest from unittest.mock import patch from typing import List @@ -752,7 +753,15 @@ def test_terminal_exceptions() -> None: def assert_complete_job(load: Load, should_delete_completed: bool = False) -> None: load_id, _ = prepare_load_package(load.load_storage, NORMALIZED_FILES) # will complete all jobs - with patch.object(dummy_impl.DummyClient, "complete_load") as complete_load: + timestamp = "2024-04-05T09:16:59.942779Z" + mocked_timestamp = {"state": {"created_at": timestamp}} + with mock.patch( + "dlt.current.load_package", + return_value=mocked_timestamp, + ), patch.object( + dummy_impl.DummyClient, + "complete_load", + ) as complete_load: with ThreadPoolExecutor() as pool: load.run(pool) # did process schema update From 0abad12c8765224cd5862434a889cc5cdc298011 Mon Sep 17 00:00:00 2001 From: rudolfix Date: Tue, 16 Apr 2024 11:03:49 +0200 Subject: [PATCH 12/41] picks file format matching item format (#1222) --- dlt/common/data_writers/__init__.py | 6 + dlt/common/data_writers/buffered.py | 4 +- dlt/common/data_writers/exceptions.py | 31 +++++ dlt/common/data_writers/writers.py | 127 ++++++++++++++++-- dlt/common/destination/__init__.py | 2 + dlt/common/destination/capabilities.py | 46 ++++++- dlt/common/storages/data_item_storage.py | 4 +- dlt/common/storages/load_storage.py | 24 +--- dlt/normalize/configuration.py | 5 +- dlt/normalize/normalize.py | 37 ++++- dlt/pipeline/pipeline.py | 76 +++-------- pyproject.toml | 2 +- .../common/data_writers/test_data_writers.py | 85 ++++++++++++ tests/common/storages/utils.py | 2 +- .../destinations/test_file_format_resolver.py | 56 ++++++++ .../data_writers/test_buffered_writer.py | 3 +- tests/extract/test_incremental.py | 1 + tests/pipeline/test_pipeline.py | 29 ++++ tests/pipeline/test_pipeline_extra.py | 50 +++++++ .../test_pipeline_file_format_resolver.py | 74 ---------- tests/tools/clean_redshift.py | 3 +- 21 files changed, 481 insertions(+), 186 deletions(-) create mode 100644 tests/destinations/test_file_format_resolver.py delete mode 100644 tests/pipeline/test_pipeline_file_format_resolver.py diff --git a/dlt/common/data_writers/__init__.py b/dlt/common/data_writers/__init__.py index 931bda962b..97451d8be7 100644 --- a/dlt/common/data_writers/__init__.py +++ b/dlt/common/data_writers/__init__.py @@ -3,6 +3,9 @@ DataWriterMetrics, TDataItemFormat, FileWriterSpec, + resolve_best_writer_spec, + get_best_writer_spec, + is_native_writer, ) from dlt.common.data_writers.buffered import BufferedDataWriter, new_file_id from dlt.common.data_writers.escape import ( @@ -14,6 +17,9 @@ __all__ = [ "DataWriter", "FileWriterSpec", + "resolve_best_writer_spec", + "get_best_writer_spec", + "is_native_writer", "DataWriterMetrics", "TDataItemFormat", "BufferedDataWriter", diff --git a/dlt/common/data_writers/buffered.py b/dlt/common/data_writers/buffered.py index 1be25ef646..fdd5b50111 100644 --- a/dlt/common/data_writers/buffered.py +++ b/dlt/common/data_writers/buffered.py @@ -47,9 +47,7 @@ def __init__( self.writer_spec = writer_spec if self.writer_spec.requires_destination_capabilities and not _caps: raise DestinationCapabilitiesRequired(self.writer_spec.file_format) - self.writer_cls = DataWriter.class_factory( - writer_spec.file_format, writer_spec.data_item_format - ) + self.writer_cls = DataWriter.writer_class_from_spec(writer_spec) self._supports_schema_changes = self.writer_spec.supports_schema_changes self._caps = _caps # validate if template has correct placeholders diff --git a/dlt/common/data_writers/exceptions.py b/dlt/common/data_writers/exceptions.py index ac339ba31c..1d5c58f787 100644 --- a/dlt/common/data_writers/exceptions.py +++ b/dlt/common/data_writers/exceptions.py @@ -1,3 +1,4 @@ +from typing import NamedTuple, Sequence from dlt.common.destination import TLoaderFileFormat from dlt.common.exceptions import DltException @@ -30,6 +31,10 @@ def __init__(self, file_format: TLoaderFileFormat): class DataWriterNotFound(DataWriterException): + pass + + +class FileFormatForItemFormatNotFound(DataWriterNotFound): def __init__(self, file_format: TLoaderFileFormat, data_item_format: str): self.file_format = file_format self.data_item_format = data_item_format @@ -39,6 +44,32 @@ def __init__(self, file_format: TLoaderFileFormat, data_item_format: str): ) +class FileSpecNotFound(KeyError, DataWriterNotFound): + def __init__(self, file_format: TLoaderFileFormat, data_item_format: str, spec: NamedTuple): + self.file_format = file_format + self.data_item_format = data_item_format + super().__init__( + f"Can't find a file writer for spec with file format {file_format} and item format" + f" {data_item_format} where the full spec is {spec}" + ) + + +class SpecLookupFailed(DataWriterNotFound): + def __init__( + self, + data_item_format: str, + possible_file_formats: Sequence[TLoaderFileFormat], + file_format: TLoaderFileFormat, + ): + self.file_format = file_format + self.possible_file_formats = possible_file_formats + self.data_item_format = data_item_format + super().__init__( + f"Lookup for best file writer for item format {data_item_format} among file formats" + f" {possible_file_formats} failed. The preferred file format was {file_format}." + ) + + class InvalidDataItem(DataWriterException): def __init__(self, file_format: TLoaderFileFormat, data_item_format: str, details: str): self.file_format = file_format diff --git a/dlt/common/data_writers/writers.py b/dlt/common/data_writers/writers.py index 60457f103e..2b14d8cd72 100644 --- a/dlt/common/data_writers/writers.py +++ b/dlt/common/data_writers/writers.py @@ -1,6 +1,5 @@ import abc import csv -from dataclasses import dataclass from typing import ( IO, TYPE_CHECKING, @@ -20,7 +19,13 @@ from dlt.common.json import json from dlt.common.configuration import configspec, known_sections, with_config from dlt.common.configuration.specs import BaseConfiguration -from dlt.common.data_writers.exceptions import DataWriterNotFound, InvalidDataItem +from dlt.common.data_writers.exceptions import ( + SpecLookupFailed, + DataWriterNotFound, + FileFormatForItemFormatNotFound, + FileSpecNotFound, + InvalidDataItem, +) from dlt.common.destination import DestinationCapabilitiesContext, TLoaderFileFormat from dlt.common.schema.typing import TTableSchemaColumns from dlt.common.typing import StrAny @@ -33,8 +38,7 @@ TWriter = TypeVar("TWriter", bound="DataWriter") -@dataclass -class FileWriterSpec: +class FileWriterSpec(NamedTuple): file_format: TLoaderFileFormat """format of the output file""" data_item_format: TDataItemFormat @@ -105,13 +109,13 @@ def from_file_format( f: IO[Any], caps: DestinationCapabilitiesContext = None, ) -> "DataWriter": - return cls.class_factory(file_format, data_item_format)(f, caps) + return cls.class_factory(file_format, data_item_format, ALL_WRITERS)(f, caps) @classmethod def writer_spec_from_file_format( cls, file_format: TLoaderFileFormat, data_item_format: TDataItemFormat ) -> FileWriterSpec: - return cls.class_factory(file_format, data_item_format).writer_spec() + return cls.class_factory(file_format, data_item_format, ALL_WRITERS).writer_spec() @classmethod def item_format_from_file_extension(cls, extension: str) -> TDataItemFormat: @@ -123,15 +127,24 @@ def item_format_from_file_extension(cls, extension: str) -> TDataItemFormat: else: raise ValueError(f"Cannot figure out data item format for extension {extension}") + @staticmethod + def writer_class_from_spec(spec: FileWriterSpec) -> Type["DataWriter"]: + try: + return WRITER_SPECS[spec] + except KeyError: + raise FileSpecNotFound(spec.file_format, spec.data_item_format, spec) + @staticmethod def class_factory( - file_format: TLoaderFileFormat, data_item_format: TDataItemFormat + file_format: TLoaderFileFormat, + data_item_format: TDataItemFormat, + writers: Sequence[Type["DataWriter"]], ) -> Type["DataWriter"]: - for writer in ALL_WRITERS: + for writer in writers: spec = writer.writer_spec() if spec.file_format == file_format and spec.data_item_format == data_item_format: return writer - raise DataWriterNotFound(file_format, data_item_format) + raise FileFormatForItemFormatNotFound(file_format, data_item_format) class JsonlWriter(DataWriter): @@ -601,8 +614,7 @@ def write_data(self, rows: Sequence[Any]) -> None: @staticmethod def convert_spec(base: Type[DataWriter]) -> FileWriterSpec: spec = base.writer_spec() - spec.data_item_format = "arrow" - return spec + return spec._replace(data_item_format="arrow") class ArrowToInsertValuesWriter(ArrowToObjectAdapter, InsertValuesWriter): @@ -623,7 +635,14 @@ def writer_spec(cls) -> FileWriterSpec: return cls.convert_spec(TypedJsonlListWriter) -# ArrowToCsvWriter +def is_native_writer(writer_type: Type[DataWriter]) -> bool: + """Checks if writer has adapter mixin. Writers with adapters are not native and typically + decrease the performance. + """ + # we only have arrow adapters now + return not issubclass(writer_type, ArrowToObjectAdapter) + + ALL_WRITERS: List[Type[DataWriter]] = [ JsonlWriter, TypedJsonlListWriter, @@ -636,3 +655,87 @@ def writer_spec(cls) -> FileWriterSpec: ArrowToTypedJsonlListWriter, ArrowToCsvWriter, ] + +WRITER_SPECS: Dict[FileWriterSpec, Type[DataWriter]] = { + writer.writer_spec(): writer for writer in ALL_WRITERS +} + +NATIVE_FORMAT_WRITERS: Dict[TDataItemFormat, Tuple[Type[DataWriter], ...]] = { + # all "object" writers are native object writers (no adapters yet) + "object": tuple( + writer + for writer in ALL_WRITERS + if writer.writer_spec().data_item_format == "object" and is_native_writer(writer) + ), + # exclude arrow adapters + "arrow": tuple( + writer + for writer in ALL_WRITERS + if writer.writer_spec().data_item_format == "arrow" and is_native_writer(writer) + ), +} + + +def resolve_best_writer_spec( + item_format: TDataItemFormat, + possible_file_formats: Sequence[TLoaderFileFormat], + preferred_format: TLoaderFileFormat = None, +) -> FileWriterSpec: + """Finds best writer for `item_format` out of `possible_file_formats`. Tries `preferred_format` first. + Best possible writer is a native writer for `item_format` writing files in `preferred_format`. + If not found, any native writer for `possible_file_formats` is picked. + Native writer supports `item_format` directly without a need to convert to other item formats. + """ + native_writers = NATIVE_FORMAT_WRITERS[item_format] + # check if preferred format has native item_format writer + if preferred_format: + if preferred_format not in possible_file_formats: + raise ValueError( + f"Preferred format {preferred_format} not possible in {possible_file_formats}" + ) + try: + return DataWriter.class_factory( + preferred_format, item_format, native_writers + ).writer_spec() + except DataWriterNotFound: + pass + # if not found, use scan native file formats for item format + for supported_format in possible_file_formats: + if supported_format != preferred_format: + try: + return DataWriter.class_factory( + supported_format, item_format, native_writers + ).writer_spec() + except DataWriterNotFound: + pass + + # search all writers + if preferred_format: + try: + return DataWriter.class_factory( + preferred_format, item_format, ALL_WRITERS + ).writer_spec() + except DataWriterNotFound: + pass + + for supported_format in possible_file_formats: + if supported_format != preferred_format: + try: + return DataWriter.class_factory( + supported_format, item_format, ALL_WRITERS + ).writer_spec() + except DataWriterNotFound: + pass + + raise SpecLookupFailed(item_format, possible_file_formats, preferred_format) + + +def get_best_writer_spec( + item_format: TDataItemFormat, file_format: TLoaderFileFormat +) -> FileWriterSpec: + """Gets writer for `item_format` writing files in {file_format}. Looks for native writer first""" + native_writers = NATIVE_FORMAT_WRITERS[item_format] + try: + return DataWriter.class_factory(file_format, item_format, native_writers).writer_spec() + except DataWriterNotFound: + return DataWriter.class_factory(file_format, item_format, ALL_WRITERS).writer_spec() diff --git a/dlt/common/destination/__init__.py b/dlt/common/destination/__init__.py index 00f129c69c..b7b98416a6 100644 --- a/dlt/common/destination/__init__.py +++ b/dlt/common/destination/__init__.py @@ -1,5 +1,6 @@ from dlt.common.destination.capabilities import ( DestinationCapabilitiesContext, + merge_caps_file_formats, TLoaderFileFormat, ALL_SUPPORTED_FILE_FORMATS, ) @@ -7,6 +8,7 @@ __all__ = [ "DestinationCapabilitiesContext", + "merge_caps_file_formats", "TLoaderFileFormat", "ALL_SUPPORTED_FILE_FORMATS", "TDestinationReferenceArg", diff --git a/dlt/common/destination/capabilities.py b/dlt/common/destination/capabilities.py index 6b06d8287e..286a295e93 100644 --- a/dlt/common/destination/capabilities.py +++ b/dlt/common/destination/capabilities.py @@ -1,8 +1,13 @@ -from typing import Any, Callable, ClassVar, List, Literal, Optional, Tuple, Set, get_args +from typing import Any, Callable, ClassVar, List, Literal, Optional, Sequence, Tuple, Set, get_args from dlt.common.configuration.utils import serialize_value from dlt.common.configuration import configspec from dlt.common.configuration.specs import ContainerInjectableContext +from dlt.common.destination.exceptions import ( + DestinationIncompatibleLoaderFileFormatException, + DestinationLoadingViaStagingNotSupported, + DestinationLoadingWithoutStagingNotSupported, +) from dlt.common.utils import identity from dlt.common.arithmetics import DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE @@ -22,9 +27,9 @@ class DestinationCapabilitiesContext(ContainerInjectableContext): """Injectable destination capabilities required for many Pipeline stages ie. normalize""" preferred_loader_file_format: TLoaderFileFormat = None - supported_loader_file_formats: List[TLoaderFileFormat] = None + supported_loader_file_formats: Sequence[TLoaderFileFormat] = None preferred_staging_file_format: Optional[TLoaderFileFormat] = None - supported_staging_file_formats: List[TLoaderFileFormat] = None + supported_staging_file_formats: Sequence[TLoaderFileFormat] = None escape_identifier: Callable[[str], str] = None escape_literal: Callable[[Any], Any] = None decimal_precision: Tuple[int, int] = None @@ -46,8 +51,8 @@ class DestinationCapabilitiesContext(ContainerInjectableContext): insert_values_writer_type: str = "default" supports_multiple_statements: bool = True supports_clone_table: bool = False - max_table_nesting: Optional[int] = None # destination can overwrite max table nesting """Destination supports CREATE TABLE ... CLONE ... statements""" + max_table_nesting: Optional[int] = None # destination can overwrite max table nesting # do not allow to create default value, destination caps must be always explicitly inserted into container can_create_default: ClassVar[bool] = False @@ -75,3 +80,36 @@ def generic_capabilities( caps.supports_transactions = True caps.supports_multiple_statements = True return caps + + +def merge_caps_file_formats( + destination: str, + staging: str, + dest_caps: DestinationCapabilitiesContext, + stage_caps: DestinationCapabilitiesContext, +) -> Tuple[TLoaderFileFormat, Sequence[TLoaderFileFormat]]: + """Merges preferred and supported file formats from destination and staging. + Returns new preferred file format and all possible formats. + """ + possible_file_formats = dest_caps.supported_loader_file_formats + if stage_caps: + if not dest_caps.supported_staging_file_formats: + raise DestinationLoadingViaStagingNotSupported(destination) + possible_file_formats = [ + f + for f in dest_caps.supported_staging_file_formats + if f in stage_caps.supported_loader_file_formats + ] + if len(possible_file_formats) == 0: + raise DestinationIncompatibleLoaderFileFormatException( + destination, staging, None, possible_file_formats + ) + if not stage_caps: + if not dest_caps.preferred_loader_file_format: + raise DestinationLoadingWithoutStagingNotSupported(destination) + requested_file_format = dest_caps.preferred_loader_file_format + elif stage_caps and dest_caps.preferred_staging_file_format in possible_file_formats: + requested_file_format = dest_caps.preferred_staging_file_format + else: + requested_file_format = possible_file_formats[0] if len(possible_file_formats) > 0 else None + return requested_file_format, possible_file_formats diff --git a/dlt/common/storages/data_item_storage.py b/dlt/common/storages/data_item_storage.py index ab15c3ad5b..f6072c0260 100644 --- a/dlt/common/storages/data_item_storage.py +++ b/dlt/common/storages/data_item_storage.py @@ -16,9 +16,7 @@ class DataItemStorage(ABC): def __init__(self, writer_spec: FileWriterSpec, *args: Any) -> None: self.writer_spec = writer_spec - self.writer_cls = DataWriter.class_factory( - writer_spec.file_format, writer_spec.data_item_format - ) + self.writer_cls = DataWriter.writer_class_from_spec(writer_spec) self.buffered_writers: Dict[str, BufferedDataWriter[DataWriter]] = {} super().__init__(*args) diff --git a/dlt/common/storages/load_storage.py b/dlt/common/storages/load_storage.py index 97e62201e5..00e95fbad9 100644 --- a/dlt/common/storages/load_storage.py +++ b/dlt/common/storages/load_storage.py @@ -82,28 +82,8 @@ def initialize_storage(self) -> None: self.storage.create_folder(LoadStorage.NORMALIZED_FOLDER, exists_ok=True) self.storage.create_folder(LoadStorage.LOADED_FOLDER, exists_ok=True) - def create_item_storage( - self, preferred_format: TLoaderFileFormat, item_format: TDataItemFormat - ) -> DataItemStorage: - """Creates item storage for preferred_format + item_format combination. If not found, it - tries the remaining file formats in supported formats. - """ - try: - return LoadItemStorage( - self.new_packages, - DataWriter.writer_spec_from_file_format(preferred_format, item_format), - ) - except DataWriterNotFound: - for supported_format in self.supported_loader_file_formats: - if supported_format != preferred_format: - try: - return LoadItemStorage( - self.new_packages, - DataWriter.writer_spec_from_file_format(supported_format, item_format), - ) - except DataWriterNotFound: - pass - raise + def create_item_storage(self, writer_spec: FileWriterSpec) -> DataItemStorage: + return LoadItemStorage(self.new_packages, writer_spec) def import_extracted_package( self, load_id: str, extract_package_storage: PackageStorage diff --git a/dlt/normalize/configuration.py b/dlt/normalize/configuration.py index 5676d23569..3bd61bfaa4 100644 --- a/dlt/normalize/configuration.py +++ b/dlt/normalize/configuration.py @@ -1,8 +1,8 @@ -from typing import TYPE_CHECKING +from typing import Optional from dlt.common.configuration import configspec from dlt.common.configuration.specs import BaseConfiguration -from dlt.common.destination import DestinationCapabilitiesContext +from dlt.common.destination import DestinationCapabilitiesContext, TLoaderFileFormat from dlt.common.runners.configuration import PoolRunnerConfiguration, TPoolType from dlt.common.storages import ( LoadStorageConfiguration, @@ -23,6 +23,7 @@ class ItemsNormalizerConfiguration(BaseConfiguration): class NormalizeConfiguration(PoolRunnerConfiguration): pool_type: TPoolType = "process" destination_capabilities: DestinationCapabilitiesContext = None # injectable + loader_file_format: Optional[TLoaderFileFormat] = None _schema_storage_config: SchemaStorageConfiguration = None _normalize_storage_config: NormalizeStorageConfiguration = None _load_storage_config: LoadStorageConfiguration = None diff --git a/dlt/normalize/normalize.py b/dlt/normalize/normalize.py index 5e3315d10f..b90c15a5f7 100644 --- a/dlt/normalize/normalize.py +++ b/dlt/normalize/normalize.py @@ -8,7 +8,14 @@ from dlt.common.configuration import with_config, known_sections from dlt.common.configuration.accessors import config from dlt.common.configuration.container import Container -from dlt.common.data_writers import DataWriter, DataWriterMetrics, TDataItemFormat +from dlt.common.data_writers import ( + DataWriter, + DataWriterMetrics, + TDataItemFormat, + resolve_best_writer_spec, + get_best_writer_spec, + is_native_writer, +) from dlt.common.data_writers.writers import EMPTY_DATA_WRITER_METRICS from dlt.common.runners import TRunMetrics, Runnable, NullExecutor from dlt.common.runtime import signals @@ -121,12 +128,30 @@ def w_normalize_files( def _get_items_normalizer(item_format: TDataItemFormat) -> ItemsNormalizer: if item_format in item_normalizers: return item_normalizers[item_format] - item_storage = load_storage.create_item_storage(preferred_file_format, item_format) - if item_storage.writer_spec.file_format != preferred_file_format: + # force file format + if config.loader_file_format: + # TODO: pass supported_formats, when used in pipeline we already checked that + # but if normalize is used standalone `supported_loader_file_formats` may be unresolved + best_writer_spec = get_best_writer_spec(item_format, config.loader_file_format) + else: + # find best spec among possible formats taking into account destination preference + best_writer_spec = resolve_best_writer_spec( + item_format, supported_formats, preferred_file_format + ) + # if best_writer_spec.file_format != preferred_file_format: + # logger.warning( + # f"For data items yielded as {item_format} jobs in file format" + # f" {preferred_file_format} cannot be created." + # f" {best_writer_spec.file_format} jobs will be used instead." + # " This may decrease the performance." + # ) + item_storage = load_storage.create_item_storage(best_writer_spec) + if not is_native_writer(item_storage.writer_cls): logger.warning( - f"For data items yielded as {item_format} job files in format" - f" {preferred_file_format} cannot be created." - f" {item_storage.writer_spec.file_format} jobs will be used instead." + f"For data items yielded as {item_format} and job file format" + f" {best_writer_spec.file_format} native writer could not be found. A" + f" {item_storage.writer_cls.__name__} writer is used that internally" + f" converts {item_format}. This will degrade performance." ) cls = ArrowItemsNormalizer if item_format == "arrow" else JsonLItemsNormalizer logger.info( diff --git a/dlt/pipeline/pipeline.py b/dlt/pipeline/pipeline.py index bdade1308f..ebff4dfa1d 100644 --- a/dlt/pipeline/pipeline.py +++ b/dlt/pipeline/pipeline.py @@ -32,11 +32,9 @@ from dlt.common.configuration.specs.config_section_context import ConfigSectionContext from dlt.common.configuration.resolve import initialize_credentials from dlt.common.destination.exceptions import ( - DestinationLoadingViaStagingNotSupported, - DestinationLoadingWithoutStagingNotSupported, + DestinationIncompatibleLoaderFileFormatException, DestinationNoStagingMode, DestinationUndefinedEntity, - DestinationIncompatibleLoaderFileFormatException, ) from dlt.common.exceptions import MissingDependencyException from dlt.common.normalizers import explicit_normalizers, import_normalizers @@ -67,6 +65,7 @@ ) from dlt.common.destination import ( DestinationCapabilitiesContext, + merge_caps_file_formats, TDestination, ALL_SUPPORTED_FILE_FORMATS, TLoaderFileFormat, @@ -470,12 +469,20 @@ def normalize( # create default normalize config normalize_config = NormalizeConfiguration( workers=workers, + loader_file_format=loader_file_format, _schema_storage_config=self._schema_storage_config, _normalize_storage_config=self._normalize_storage_config(), _load_storage_config=self._load_storage_config(), ) # run with destination context - with self._maybe_destination_capabilities(loader_file_format=loader_file_format): + with self._maybe_destination_capabilities() as caps: + if loader_file_format and loader_file_format not in caps.supported_loader_file_formats: + raise DestinationIncompatibleLoaderFileFormatException( + self.destination.destination_name, + (self.staging.destination_name if self.staging else None), + loader_file_format, + set(caps.supported_loader_file_formats), + ) # shares schema storage with the pipeline so we do not need to install normalize_step: Normalize = Normalize( collector=self.collector, @@ -759,8 +766,8 @@ def sync_destination( self._wipe_working_folder() state = self._get_state() self._configure( - self._schema_storage_config.export_schema_path, self._schema_storage_config.import_schema_path, + self._schema_storage_config.export_schema_path, False, ) @@ -1262,7 +1269,7 @@ def _set_destinations( @contextmanager def _maybe_destination_capabilities( - self, loader_file_format: TLoaderFileFormat = None + self, ) -> Iterator[DestinationCapabilitiesContext]: try: caps: DestinationCapabilitiesContext = None @@ -1273,62 +1280,19 @@ def _maybe_destination_capabilities( injected_caps = self._container.injectable_context(destination_caps) caps = injected_caps.__enter__() - caps.preferred_loader_file_format = self._resolve_loader_file_format( - self.destination.destination_name, - ( - # DestinationReference.to_name(self.destination), - self.staging.destination_name - if self.staging - else None - ), - # DestinationReference.to_name(self.staging) if self.staging else None, - destination_caps, - stage_caps, - loader_file_format, + caps.preferred_loader_file_format, caps.supported_loader_file_formats = ( + merge_caps_file_formats( + self.destination.destination_name, + (self.staging.destination_name if self.staging else None), + destination_caps, + stage_caps, + ) ) - caps.supported_loader_file_formats = ( - destination_caps.supported_staging_file_formats if stage_caps else None - ) or destination_caps.supported_loader_file_formats yield caps finally: if injected_caps: injected_caps.__exit__(None, None, None) - @staticmethod - def _resolve_loader_file_format( - destination: str, - staging: str, - dest_caps: DestinationCapabilitiesContext, - stage_caps: DestinationCapabilitiesContext, - file_format: TLoaderFileFormat, - ) -> TLoaderFileFormat: - possible_file_formats = dest_caps.supported_loader_file_formats - if stage_caps: - if not dest_caps.supported_staging_file_formats: - raise DestinationLoadingViaStagingNotSupported(destination) - possible_file_formats = [ - f - for f in dest_caps.supported_staging_file_formats - if f in stage_caps.supported_loader_file_formats - ] - if not file_format: - if not stage_caps: - if not dest_caps.preferred_loader_file_format: - raise DestinationLoadingWithoutStagingNotSupported(destination) - file_format = dest_caps.preferred_loader_file_format - elif stage_caps and dest_caps.preferred_staging_file_format in possible_file_formats: - file_format = dest_caps.preferred_staging_file_format - else: - file_format = possible_file_formats[0] if len(possible_file_formats) > 0 else None - if file_format not in possible_file_formats: - raise DestinationIncompatibleLoaderFileFormatException( - destination, - staging, - file_format, - set(possible_file_formats), - ) - return file_format - def _set_default_normalizers(self) -> None: _, self._default_naming, _ = import_normalizers(explicit_normalizers()) diff --git a/pyproject.toml b/pyproject.toml index 9216571613..c0630fd9b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dlt" -version = "0.4.8" +version = "0.4.9a0" description = "dlt is an open-source python-first scalable data loading library that does not require any backend to run." authors = ["dltHub Inc. "] maintainers = [ "Marcin Rudolf ", "Adrian Brudaru ", "Ty Dunn "] diff --git a/tests/common/data_writers/test_data_writers.py b/tests/common/data_writers/test_data_writers.py index 456ac64996..6cc7cb55ab 100644 --- a/tests/common/data_writers/test_data_writers.py +++ b/tests/common/data_writers/test_data_writers.py @@ -4,6 +4,7 @@ from typing import Iterator from dlt.common import pendulum, json +from dlt.common.data_writers.exceptions import DataWriterNotFound, SpecLookupFailed from dlt.common.typing import AnyFun # from dlt.destinations.postgres import capabilities @@ -18,11 +19,21 @@ # import all writers here to check if it can be done without all the dependencies from dlt.common.data_writers.writers import ( + WRITER_SPECS, + ArrowToCsvWriter, + ArrowToInsertValuesWriter, + ArrowToJsonlWriter, + ArrowToParquetWriter, + ArrowToTypedJsonlListWriter, + CsvWriter, DataWriter, DataWriterMetrics, EMPTY_DATA_WRITER_METRICS, InsertValuesWriter, JsonlWriter, + get_best_writer_spec, + resolve_best_writer_spec, + is_native_writer, ) from tests.common.utils import load_json_case, row_to_column_schemas @@ -174,3 +185,77 @@ def test_data_writer_metrics_add() -> None: # time range extends when added add_m = metrics + DataWriterMetrics("file", 99, 120, now - 10, now + 20) # type: ignore[assignment] assert add_m == DataWriterMetrics("", 109, 220, now - 10, now + 20) + + +def test_is_native_writer() -> None: + assert is_native_writer(InsertValuesWriter) + assert is_native_writer(ArrowToCsvWriter) + assert is_native_writer(CsvWriter) + # and not one with adapter + assert is_native_writer(ArrowToJsonlWriter) is False + + +def test_resolve_best_writer() -> None: + assert ( + WRITER_SPECS[ + resolve_best_writer_spec("arrow", ("insert_values", "jsonl", "parquet", "csv")) + ] + == ArrowToParquetWriter + ) + assert WRITER_SPECS[resolve_best_writer_spec("object", ("jsonl",))] == JsonlWriter + # order of what is possible matters + assert ( + WRITER_SPECS[ + resolve_best_writer_spec("arrow", ("insert_values", "jsonl", "csv", "parquet")) + ] + == ArrowToCsvWriter + ) + # jsonl is ignored because it is not native + assert ( + WRITER_SPECS[ + resolve_best_writer_spec("arrow", ("insert_values", "jsonl", "parquet"), "jsonl") + ] + == ArrowToParquetWriter + ) + # csv not possible + with pytest.raises(ValueError): + resolve_best_writer_spec("arrow", ("insert_values", "jsonl", "parquet"), "csv") + # csv is native and preferred so goes over parquet + assert ( + WRITER_SPECS[ + resolve_best_writer_spec("arrow", ("insert_values", "jsonl", "parquet", "csv"), "csv") + ] + == ArrowToCsvWriter + ) + + # not a native writer + assert ( + WRITER_SPECS[ + resolve_best_writer_spec( + "arrow", + ("insert_values",), + ) + ] + == ArrowToInsertValuesWriter + ) + assert ( + WRITER_SPECS[ + resolve_best_writer_spec("arrow", ("insert_values", "typed-jsonl"), "typed-jsonl") + ] + == ArrowToTypedJsonlListWriter + ) + + # no route + with pytest.raises(SpecLookupFailed) as spec_ex: + resolve_best_writer_spec("arrow", ("tsv",), "tsv") # type: ignore[arg-type] + assert spec_ex.value.possible_file_formats == ("tsv",) + assert spec_ex.value.data_item_format == "arrow" + assert spec_ex.value.file_format == "tsv" + + +def test_get_best_writer() -> None: + assert WRITER_SPECS[get_best_writer_spec("arrow", "csv")] == ArrowToCsvWriter + assert WRITER_SPECS[get_best_writer_spec("object", "csv")] == CsvWriter + assert WRITER_SPECS[get_best_writer_spec("arrow", "insert_values")] == ArrowToInsertValuesWriter + with pytest.raises(DataWriterNotFound): + get_best_writer_spec("arrow", "tsv") # type: ignore diff --git a/tests/common/storages/utils.py b/tests/common/storages/utils.py index 13ec253e2f..0eb472a9af 100644 --- a/tests/common/storages/utils.py +++ b/tests/common/storages/utils.py @@ -133,7 +133,7 @@ def start_loading_file( load_id = uniq_id() s.new_packages.create_package(load_id) # write test file - item_storage = s.create_item_storage("jsonl", "object") + item_storage = s.create_item_storage(DataWriter.writer_spec_from_file_format("jsonl", "object")) file_name = write_temp_job_file( item_storage, s.storage, load_id, "mock_table", None, uniq_id(), content ) diff --git a/tests/destinations/test_file_format_resolver.py b/tests/destinations/test_file_format_resolver.py new file mode 100644 index 0000000000..f681a0eebd --- /dev/null +++ b/tests/destinations/test_file_format_resolver.py @@ -0,0 +1,56 @@ +from typing import List, TYPE_CHECKING +import pytest + +from dlt.common.destination.exceptions import DestinationIncompatibleLoaderFileFormatException +from dlt.common.destination.capabilities import ( + DestinationCapabilitiesContext, + merge_caps_file_formats, +) + + +def test_file_format_resolution() -> None: + if TYPE_CHECKING: + cp = DestinationCapabilitiesContext + + class cp: # type: ignore[no-redef] + def __init__(self) -> None: + self.preferred_loader_file_format: str = None + self.supported_loader_file_formats: List[str] = [] + self.preferred_staging_file_format: str = None + self.supported_staging_file_formats: List[str] = [] + + destcp = cp() + stagecp = cp() + + # check regular resolution + destcp.preferred_loader_file_format = "jsonl" + destcp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] + assert merge_caps_file_formats("some", "some_s", destcp, None) == ( + "jsonl", + ["jsonl", "insert_values", "parquet"], + ) + + # check staging resolution with clear preference + destcp.supported_staging_file_formats = ["jsonl", "insert_values", "parquet"] + destcp.preferred_staging_file_format = "insert_values" + stagecp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] + assert merge_caps_file_formats("some", "some_s", destcp, stagecp) == ( + "insert_values", + ["jsonl", "insert_values", "parquet"], + ) + + # check staging resolution where preference does not match + destcp.supported_staging_file_formats = ["insert_values", "parquet"] + destcp.preferred_staging_file_format = "tsv" # type: ignore[assignment] + stagecp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] + assert merge_caps_file_formats("some", "some_s", destcp, stagecp) == ( + "insert_values", + ["insert_values", "parquet"], + ) + + # check incompatible staging + destcp.supported_staging_file_formats = ["insert_values", "tsv"] # type: ignore[list-item] + destcp.preferred_staging_file_format = "tsv" # type: ignore[assignment] + stagecp.supported_loader_file_formats = ["jsonl", "parquet"] + with pytest.raises(DestinationIncompatibleLoaderFileFormatException): + merge_caps_file_formats("some", "some_s", destcp, stagecp) diff --git a/tests/extract/data_writers/test_buffered_writer.py b/tests/extract/data_writers/test_buffered_writer.py index a1b4be3999..82b81a1cd7 100644 --- a/tests/extract/data_writers/test_buffered_writer.py +++ b/tests/extract/data_writers/test_buffered_writer.py @@ -151,7 +151,8 @@ def c2_doc(count: int) -> Iterator[DictStrAny]: writer=JsonlWriter, file_max_items=100, disable_compression=disable_compression ) as writer: # mock spec - writer._supports_schema_changes = writer.writer_spec.supports_schema_changes = "False" + writer._supports_schema_changes = "False" + writer.writer_spec = writer.writer_spec._replace(supports_schema_changes="False") # write 1 doc writer.write_data_item(list(c1_doc(1)), t1) # in buffer diff --git a/tests/extract/test_incremental.py b/tests/extract/test_incremental.py index a1101fddb1..7fb9c39194 100644 --- a/tests/extract/test_incremental.py +++ b/tests/extract/test_incremental.py @@ -1353,6 +1353,7 @@ async def descending( ) -> Any: for chunk in chunks(count(start=48, step=-1), 10): await asyncio.sleep(0.01) + print(updated_at.start_value) data = [{"updated_at": i} for i in chunk] yield data_to_item_format(item_type, data) diff --git a/tests/pipeline/test_pipeline.py b/tests/pipeline/test_pipeline.py index 37356c2b44..0cbaa37735 100644 --- a/tests/pipeline/test_pipeline.py +++ b/tests/pipeline/test_pipeline.py @@ -22,6 +22,9 @@ from dlt.common.destination.reference import WithStateSync from dlt.common.destination.exceptions import ( DestinationHasFailedJobs, + DestinationIncompatibleLoaderFileFormatException, + DestinationLoadingViaStagingNotSupported, + DestinationNoStagingMode, DestinationTerminalException, UnknownDestinationModule, ) @@ -134,6 +137,32 @@ def test_pipeline_with_non_alpha_name() -> None: assert p.default_schema_name == "another_pipeline_8329x" +def test_file_format_resolution() -> None: + # raise on destinations that does not support staging + with pytest.raises(DestinationLoadingViaStagingNotSupported): + dlt.pipeline( + pipeline_name="managed_state_pipeline", destination="postgres", staging="filesystem" + ) + + # raise on staging that does not support staging interface + with pytest.raises(DestinationNoStagingMode): + dlt.pipeline(pipeline_name="managed_state_pipeline", staging="postgres") + + # check invalid input + with pytest.raises(DestinationIncompatibleLoaderFileFormatException): + pipeline = dlt.pipeline(pipeline_name="managed_state_pipeline", destination="postgres") + pipeline.config.restore_from_destination = False + pipeline.run([1, 2, 3], table_name="numbers", loader_file_format="parquet") + + # check invalid input + with pytest.raises(DestinationIncompatibleLoaderFileFormatException): + pipeline = dlt.pipeline( + pipeline_name="managed_state_pipeline", destination="athena", staging="filesystem" + ) + pipeline.config.restore_from_destination = False + pipeline.run([1, 2, 3], table_name="numbers", loader_file_format="insert_values") + + def test_invalid_dataset_name() -> None: # this is invalid dataset name but it will be normalized within a destination p = dlt.pipeline(dataset_name="!") diff --git a/tests/pipeline/test_pipeline_extra.py b/tests/pipeline/test_pipeline_extra.py index 98323a2412..7208216c9f 100644 --- a/tests/pipeline/test_pipeline_extra.py +++ b/tests/pipeline/test_pipeline_extra.py @@ -467,3 +467,53 @@ def users(): table = pq.read_table(os.path.abspath(test_storage.make_full_path(files[0]))) assert table.num_rows == 0 assert set(table.schema.names) == {"id", "name", "_dlt_load_id", "_dlt_id"} + + +def test_pick_matching_file_format(test_storage: FileStorage) -> None: + from dlt.destinations import filesystem + + local = filesystem(os.path.abspath(TEST_STORAGE_ROOT)) + + import pyarrow as pa + + data = { + "Numbers": [1, 2, 3, 4, 5], + "Strings": ["apple", "banana", "cherry", "date", "elderberry"], + } + + df = pa.table(data) + + # load arrow and object to filesystem. we should get a parquet and a jsonl file + info = dlt.run( + [ + dlt.resource([data], name="object"), + dlt.resource(df, name="arrow"), + ], + destination=local, + dataset_name="user_data", + ) + assert_load_info(info) + files = test_storage.list_folder_files("user_data/arrow") + assert len(files) == 1 + assert files[0].endswith("parquet") + files = test_storage.list_folder_files("user_data/object") + assert len(files) == 1 + assert files[0].endswith("jsonl") + + # load as csv + info = dlt.run( + [ + dlt.resource([data], name="object"), + dlt.resource(df, name="arrow"), + ], + destination=local, + dataset_name="user_data_csv", + loader_file_format="csv", + ) + assert_load_info(info) + files = test_storage.list_folder_files("user_data_csv/arrow") + assert len(files) == 1 + assert files[0].endswith("csv") + files = test_storage.list_folder_files("user_data_csv/object") + assert len(files) == 1 + assert files[0].endswith("csv") diff --git a/tests/pipeline/test_pipeline_file_format_resolver.py b/tests/pipeline/test_pipeline_file_format_resolver.py deleted file mode 100644 index 82afcd7dfb..0000000000 --- a/tests/pipeline/test_pipeline_file_format_resolver.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import List, TYPE_CHECKING - -import dlt -import pytest - -from dlt.common.destination.exceptions import ( - DestinationIncompatibleLoaderFileFormatException, - DestinationLoadingViaStagingNotSupported, - DestinationNoStagingMode, -) -from dlt.common.destination.capabilities import DestinationCapabilitiesContext - - -def test_file_format_resolution() -> None: - # raise on destinations that does not support staging - with pytest.raises(DestinationLoadingViaStagingNotSupported): - p = dlt.pipeline( - pipeline_name="managed_state_pipeline", destination="postgres", staging="filesystem" - ) - - # raise on staging that does not support staging interface - with pytest.raises(DestinationNoStagingMode): - p = dlt.pipeline(pipeline_name="managed_state_pipeline", staging="postgres") - - p = dlt.pipeline(pipeline_name="managed_state_pipeline") - - if TYPE_CHECKING: - cp = DestinationCapabilitiesContext - - class cp: # type: ignore[no-redef] - def __init__(self) -> None: - self.preferred_loader_file_format: str = None - self.supported_loader_file_formats: List[str] = [] - self.preferred_staging_file_format: str = None - self.supported_staging_file_formats: List[str] = [] - - destcp = cp() - stagecp = cp() - - # check regular resolution - destcp.preferred_loader_file_format = "jsonl" - destcp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] - assert p._resolve_loader_file_format("some", "some", destcp, None, None) == "jsonl" - - # check resolution with input - assert p._resolve_loader_file_format("some", "some", destcp, None, "parquet") == "parquet" - - # check invalid input - with pytest.raises(DestinationIncompatibleLoaderFileFormatException): - assert p._resolve_loader_file_format("some", "some", destcp, None, "tsv") # type: ignore[arg-type] - - # check staging resolution with clear preference - destcp.supported_staging_file_formats = ["jsonl", "insert_values", "parquet"] - destcp.preferred_staging_file_format = "insert_values" - stagecp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] - assert p._resolve_loader_file_format("some", "some", destcp, stagecp, None) == "insert_values" - - # check invalid input - with pytest.raises(DestinationIncompatibleLoaderFileFormatException): - p._resolve_loader_file_format("some", "some", destcp, stagecp, "tsv") # type: ignore[arg-type] - - # check staging resolution where preference does not match - destcp.supported_staging_file_formats = ["insert_values", "parquet"] - destcp.preferred_staging_file_format = "tsv" # type: ignore[assignment] - stagecp.supported_loader_file_formats = ["jsonl", "insert_values", "parquet"] - assert p._resolve_loader_file_format("some", "some", destcp, stagecp, None) == "insert_values" - assert p._resolve_loader_file_format("some", "some", destcp, stagecp, "parquet") == "parquet" - - # check incompatible staging - destcp.supported_staging_file_formats = ["insert_values", "tsv"] # type: ignore[list-item] - destcp.preferred_staging_file_format = "tsv" # type: ignore[assignment] - stagecp.supported_loader_file_formats = ["jsonl", "parquet"] - with pytest.raises(DestinationIncompatibleLoaderFileFormatException): - p._resolve_loader_file_format("some", "some", destcp, stagecp, None) diff --git a/tests/tools/clean_redshift.py b/tests/tools/clean_redshift.py index f81407f74a..96364d68fb 100644 --- a/tests/tools/clean_redshift.py +++ b/tests/tools/clean_redshift.py @@ -26,6 +26,7 @@ print(f"Deleting {schema}...") try: curr.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE;") - except (InsufficientPrivilege, InternalError_, SyntaxError): + except (InsufficientPrivilege, InternalError_, SyntaxError) as ex: + print(ex) pass print(f"Deleted {schema}...") From a8d721be5a3a45f429df0b324c7d16a82bdff8c5 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Wed, 17 Apr 2024 13:47:13 +0200 Subject: [PATCH 13/41] Update tzdata to 2024.1 (#1223) * Update tzdata to 2024.1 * Update lock hash --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 366332ecbb..257714ad6a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8635,13 +8635,13 @@ files = [ [[package]] name = "tzdata" -version = "2023.3" +version = "2024.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, - {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] [[package]] @@ -9090,4 +9090,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<3.13" -content-hash = "b8808caf87b2a80d4fa977696580588f5057c7ab384d3439abca5a444e1f6e41" +content-hash = "0bd3559c3b2e0ad8a33bfdb81586f1db8399d862728e8899b259961c8e175abf" diff --git a/pyproject.toml b/pyproject.toml index c0630fd9b1..301ac726ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ simplejson = ">=3.17.5" PyYAML = ">=5.4.1" semver = ">=2.13.0" hexbytes = ">=0.2.2" -tzdata = ">=2022.1" +tzdata = ">=2024.1" tomlkit = ">=0.11.3" pathvalidate = ">=2.5.2" typing-extensions = ">=4.0.0" From c1f2b8f05da56ea7fb7cad79ba13b1d870778689 Mon Sep 17 00:00:00 2001 From: Roman Peresypkin Date: Wed, 17 Apr 2024 09:57:22 -0400 Subject: [PATCH 14/41] fix athena iceberg's trailing location (#1230) Co-authored-by: roman peresypkin --- dlt/destinations/impl/athena/athena.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlt/destinations/impl/athena/athena.py b/dlt/destinations/impl/athena/athena.py index 1beb249386..25152ec06f 100644 --- a/dlt/destinations/impl/athena/athena.py +++ b/dlt/destinations/impl/athena/athena.py @@ -367,7 +367,7 @@ def _get_table_update_sql( if is_iceberg: sql.append(f"""CREATE TABLE {qualified_table_name} ({columns}) - LOCATION '{location}' + LOCATION '{location.rstrip('/')}' TBLPROPERTIES ('table_type'='ICEBERG', 'format'='parquet');""") elif table_format == "jsonl": sql.append(f"""CREATE EXTERNAL TABLE {qualified_table_name} From 95e11f3727d6f9f440f5b86f12c652311df16b4a Mon Sep 17 00:00:00 2001 From: VioletM Date: Wed, 17 Apr 2024 16:07:16 +0200 Subject: [PATCH 15/41] Pass options to parse iso like strings (#1219) * Pass options to parse iso like strings * Update testcase for iso detection --- dlt/common/time.py | 16 ++++++++++------ tests/common/schema/test_detections.py | 2 +- tests/common/test_time.py | 2 ++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dlt/common/time.py b/dlt/common/time.py index 161205deb8..b7be589b67 100644 --- a/dlt/common/time.py +++ b/dlt/common/time.py @@ -1,12 +1,16 @@ import contextlib -from typing import Any, Optional, Union, overload, TypeVar, Callable # noqa import datetime # noqa: I251 +from typing import Any, Optional, Union, overload, TypeVar, Callable # noqa -from dlt.common.pendulum import pendulum, timedelta -from dlt.common.typing import TimedeltaSeconds, TAnyDateTime -from pendulum.parsing import parse_iso8601, _parse_common as parse_datetime_common +from pendulum.parsing import ( + parse_iso8601, + DEFAULT_OPTIONS as pendulum_options, + _parse_common as parse_datetime_common, +) from pendulum.tz import UTC +from dlt.common.pendulum import pendulum, timedelta +from dlt.common.typing import TimedeltaSeconds, TAnyDateTime PAST_TIMESTAMP: float = 0.0 FUTURE_TIMESTAMP: float = 9999999999.0 @@ -45,7 +49,7 @@ def timestamp_before(timestamp: float, max_inclusive: Optional[float]) -> bool: def parse_iso_like_datetime(value: Any) -> Union[pendulum.DateTime, pendulum.Date, pendulum.Time]: """Parses ISO8601 string into pendulum datetime, date or time. Preserves timezone info. - Note: naive datetimes will generated from string without timezone + Note: naive datetimes will be generated from string without timezone we use internal pendulum parse function. the generic function, for example, parses string "now" as now() it also tries to parse ISO intervals but the code is very low quality @@ -56,7 +60,7 @@ def parse_iso_like_datetime(value: Any) -> Union[pendulum.DateTime, pendulum.Dat dtv = parse_iso8601(value) # now try to parse a set of ISO like dates if not dtv: - dtv = parse_datetime_common(value) + dtv = parse_datetime_common(value, **pendulum_options) if isinstance(dtv, datetime.time): return pendulum.time(dtv.hour, dtv.minute, dtv.second, dtv.microsecond) if isinstance(dtv, datetime.datetime): diff --git a/tests/common/schema/test_detections.py b/tests/common/schema/test_detections.py index 61ce0ede45..1dff8c2eb8 100644 --- a/tests/common/schema/test_detections.py +++ b/tests/common/schema/test_detections.py @@ -52,6 +52,7 @@ def test_iso_date_detection() -> None: # ISO-8601 allows dates with reduced precision assert is_iso_date(str, "1975-05") == "date" assert is_iso_date(str, "1975") == "date" + assert is_iso_date(str, "1975/05/01") == "date" # dont auto-detect timestamps as dates assert is_iso_date(str, str(pendulum.now())) is None @@ -68,7 +69,6 @@ def test_iso_date_detection() -> None: assert is_iso_date(str, "") is None assert is_iso_date(str, "75") is None assert is_iso_date(str, "01-12") is None - assert is_iso_date(str, "1975/05/01") is None # wrong type assert is_iso_date(float, str(pendulum.now().date())) is None diff --git a/tests/common/test_time.py b/tests/common/test_time.py index 7568e84046..d7c9ae3ed7 100644 --- a/tests/common/test_time.py +++ b/tests/common/test_time.py @@ -80,6 +80,8 @@ def test_before() -> None: def test_parse_iso_like_datetime() -> None: # naive datetime is still naive assert parse_iso_like_datetime("2021-01-01T05:02:32") == pendulum.DateTime(2021, 1, 1, 5, 2, 32) + # test that _parse_common form pendulum parsing is not failing with KeyError + assert parse_iso_like_datetime("2021:01:01 05:02:32") == pendulum.DateTime(2021, 1, 1, 5, 2, 32) @pytest.mark.parametrize("date_value, expected", test_params) From 0dde058dbe9d2f60f01d7799d6ab50fa2f6a0b99 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:39:06 +0200 Subject: [PATCH 16/41] Docs: Extend layout placeholder params for filesystem destinations (#1220) * Add section about new placeholders * Add basic information about additional placeholders * Add more examples of layout configuration * Add code snippet examples * Remove typing info * Add note * Add note about auto_mkdir * Try concurrent snippet linting * Try concurrent snippet linting * Adjust wording and format check_embedded_snippets.py * Uncomment examples and submit task to pool properly * Submit snippets to workers * Revert parallelization stuff * Comment out unused laoyuts * Fix mypy issues * Add a section about the recommended layout * Adjust text * Better text * Adjust section titles * Adjust code section language identifier * Fix mypy errors * More cosmetic changes for the doc --------- Co-authored-by: Violetta Mishechkina --- docs/tools/check_embedded_snippets.py | 12 +- .../dlt-ecosystem/destinations/filesystem.md | 228 ++++++++++++++---- 2 files changed, 193 insertions(+), 47 deletions(-) diff --git a/docs/tools/check_embedded_snippets.py b/docs/tools/check_embedded_snippets.py index 96e1227745..b6772f4529 100644 --- a/docs/tools/check_embedded_snippets.py +++ b/docs/tools/check_embedded_snippets.py @@ -1,14 +1,21 @@ """ Walks through all markdown files, finds all code snippets, and checks wether they are parseable. """ -from typing import List, Dict, Optional +import os +import ast +import subprocess +import argparse -import os, ast, json, yaml, tomlkit, subprocess, argparse # noqa: I251 from dataclasses import dataclass from textwrap import dedent +from typing import List +import tomlkit +import yaml import dlt.cli.echo as fmt +from dlt.common import json + from utils import collect_markdown_files @@ -295,6 +302,7 @@ def typecheck_snippets(snippets: List[Snippet], verbose: bool) -> None: python_snippets = [s for s in filtered_snippets if s.language == "py"] if args.command in ["lint", "full"]: lint_snippets(python_snippets, args.verbose) + if ENABLE_MYPY and args.command in ["typecheck", "full"]: typecheck_snippets(python_snippets, args.verbose) diff --git a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md index a8b2b084b9..3124026bd5 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md +++ b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md @@ -1,7 +1,5 @@ # Filesystem & buckets -Filesystem destination stores data in remote file systems and bucket storages like **S3**, **google storage** or **azure blob storage**. -Underneath, it uses [fsspec](https://github.com/fsspec/filesystem_spec) to abstract file operations. -Its primary role is to be used as a staging for other destinations, but you can also quickly build a data lake with it. +The Filesystem destination stores data in remote file systems and bucket storages like **S3**, **Google Storage**, or **Azure Blob Storage**. Underneath, it uses [fsspec](https://github.com/fsspec/filesystem_spec) to abstract file operations. Its primary role is to be used as a staging for other destinations, but you can also quickly build a data lake with it. > 💡 Please read the notes on the layout of the data files. Currently, we are getting feedback on it. Please join our Slack (icon at the top of the page) and help us find the optimal layout. @@ -15,8 +13,7 @@ This installs `s3fs` and `botocore` packages. :::caution -You may also install the dependencies independently. -Try: +You may also install the dependencies independently. Try: ```sh pip install dlt pip install s3fs @@ -28,16 +25,18 @@ so pip does not fail on backtracking. ### 1. Initialise the dlt project -Let's start by initialising a new dlt project as follows: +Let's start by initializing a new dlt project as follows: ```sh dlt init chess filesystem ``` - > 💡 This command will initialise your pipeline with chess as the source and the AWS S3 filesystem as the destination. +:::note +This command will initialize your pipeline with chess as the source and the AWS S3 filesystem as the destination. +::: ### 2. Set up bucket storage and credentials #### AWS S3 -The command above creates sample `secrets.toml` and requirements file for AWS S3 bucket. You can install those dependencies by running: +The command above creates a sample `secrets.toml` and requirements file for AWS S3 bucket. You can install those dependencies by running: ```sh pip install -r requirements.txt ``` @@ -52,9 +51,7 @@ aws_access_key_id = "please set me up!" # copy the access key here aws_secret_access_key = "please set me up!" # copy the secret access key here ``` -If you have your credentials stored in `~/.aws/credentials` just remove the **[destination.filesystem.credentials]** section above -and `dlt` will fall back to your **default** profile in local credentials. -If you want to switch the profile, pass the profile name as follows (here: `dlt-ci-user`): +If you have your credentials stored in `~/.aws/credentials`, just remove the **[destination.filesystem.credentials]** section above, and `dlt` will fall back to your **default** profile in local credentials. If you want to switch the profile, pass the profile name as follows (here: `dlt-ci-user`): ```toml [destination.filesystem.credentials] profile_name="dlt-ci-user" @@ -66,7 +63,7 @@ You can also pass an AWS region: region_name="eu-central-1" ``` -You need to create a S3 bucket and a user who can access that bucket. `dlt` is not creating buckets automatically. +You need to create an S3 bucket and a user who can access that bucket. `dlt` does not create buckets automatically. 1. You can create the S3 bucket in the AWS console by clicking on "Create Bucket" in S3 and assigning the appropriate name and permissions to the bucket. 2. Once the bucket is created, you'll have the bucket URL. For example, If the bucket name is `dlt-ci-test-bucket`, then the bucket URL will be: @@ -76,7 +73,7 @@ You need to create a S3 bucket and a user who can access that bucket. `dlt` is n ``` 3. To grant permissions to the user being used to access the S3 bucket, go to the IAM > Users, and click on “Add Permissions”. -4. Below you can find a sample policy that gives a minimum permission required by `dlt` to a bucket we created above. The policy contains permissions to list files in a bucket, get, put and delete objects. **Remember to place your bucket name in Resource section of the policy!** +4. Below you can find a sample policy that gives a minimum permission required by `dlt` to a bucket we created above. The policy contains permissions to list files in a bucket, get, put, and delete objects. **Remember to place your bucket name in the Resource section of the policy!** ```json { @@ -105,7 +102,7 @@ You need to create a S3 bucket and a user who can access that bucket. `dlt` is n ##### Using S3 compatible storage -To use an S3 compatible storage other than AWS S3 like [MinIO](https://min.io/) or [Cloudflare R2](https://www.cloudflare.com/en-ca/developer-platform/r2/) you may supply an `endpoint_url` in the config. This should be set along with aws credentials: +To use an S3 compatible storage other than AWS S3 like [MinIO](https://min.io/) or [Cloudflare R2](https://www.cloudflare.com/en-ca/developer-platform/r2/), you may supply an `endpoint_url` in the config. This should be set along with AWS credentials: ```toml [destination.filesystem] @@ -123,12 +120,12 @@ To pass any additional arguments to `fsspec`, you may supply `kwargs` and `clien ```toml [destination.filesystem] -kwargs = '{"use_ssl": true}' +kwargs = '{"use_ssl": true, "auto_mkdir": true}' client_kwargs = '{"verify": "public.crt"}' ``` #### Google Storage -Run `pip install dlt[gs]` which will install `gcfs` package. +Run `pip install dlt[gs]` which will install the `gcfs` package. To edit the `dlt` credentials file with your secret info, open `.dlt/secrets.toml`. You'll see AWS credentials by default. @@ -142,8 +139,9 @@ project_id = "project_id" # please set me up! private_key = "private_key" # please set me up! client_email = "client_email" # please set me up! ``` - -> 💡 Note that you can share the same credentials with BigQuery, replace the **[destination.filesystem.credentials]** section with less specific one: **[destination.credentials]** which applies to both destinations +:::note +Note that you can share the same credentials with BigQuery, replace the `[destination.filesystem.credentials]` section with a less specific one: `[destination.credentials]` which applies to both destinations +::: if you have default google cloud credentials in your environment (i.e. on cloud function) remove the credentials sections above and `dlt` will fall back to the available default. @@ -171,18 +169,18 @@ you can omit both `azure_storage_account_key` and `azure_storage_sas_token` and Note that `azure_storage_account_name` is still required as it can't be inferred from the environment. #### Local file system -If for any reason you want to have those files in local folder, set up the `bucket_url` as follows (you are free to use `config.toml` for that as there are no secrets required) +If for any reason you want to have those files in a local folder, set up the `bucket_url` as follows (you are free to use `config.toml` for that as there are no secrets required) ```toml [destination.filesystem] -bucket_url = "file:///absolute/path" # three / for absolute path +bucket_url = "file:///absolute/path" # three / for an absolute path # bucket_url = "file://relative/path" # two / for a relative path ``` ## Write disposition -`filesystem` destination handles the write dispositions as follows: -- `append` - files belonging to such tables are added to dataset folder -- `replace` - all files that belong to such tables are deleted from dataset folder, and then the current set of files is added. +The filesystem destination handles the write dispositions as follows: +- `append` - files belonging to such tables are added to the dataset folder +- `replace` - all files that belong to such tables are deleted from the dataset folder, and then the current set of files is added. - `merge` - falls back to `append` ## File Compression @@ -192,47 +190,99 @@ The filesystem destination in the dlt library uses `gzip` compression by default To handle compressed files: - To disable compression, you can modify the `data_writer.disable_compression` setting in your "config.toml" file. This can be useful if you want to access the files directly without needing to decompress them. For example: - ```toml - [normalize.data_writer] - disable_compression=true - ``` + +```toml +[normalize.data_writer] +disable_compression=true +``` - To decompress a `gzip` file, you can use tools like `gunzip`. This will convert the compressed file back to its original format, making it readable. For more details on managing file compression, please visit our documentation on performance optimization: [Disabling and Enabling File Compression](https://dlthub.com/docs/reference/performance#disabling-and-enabling-file-compression). -## Data loading -All the files are stored in a single folder with the name of the dataset that you passed to the `run` or `load` methods of `pipeline`. In our example chess pipeline it is **chess_players_games_data**. +## Files layout +All the files are stored in a single folder with the name of the dataset that you passed to the `run` or `load` methods of the `pipeline`. In our example chess pipeline, it is **chess_players_games_data**. -> 💡 Note that bucket storages are in fact key-blob storage so folder structure is emulated by splitting file names into components by `/`. +:::note +Bucket storages are, in fact, key-blob storage so the folder structure is emulated by splitting file names into components by separator (`/`). +::: -### Files layout +You can control files layout by specifying the desired configuration. There are several ways to do this. -The name of each file contains essential metadata on the content: +### Default layout -- **schema_name** and **table_name** identify the [schema](../../general-usage/schema.md) and table that define the file structure (column names, data types, etc.) -- **load_id** is the [id of the load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) form which the file comes from. -- **file_id** is there are many files with data for a single table, they are copied with different file id. -- **ext** a format of the file i.e. `jsonl` or `parquet` +Current default layout: `{table_name}/{load_id}.{file_id}.{ext}` -Current default layout: **{table_name}/{load_id}.{file_id}.{ext}`** +:::note +The default layout format has changed from `{schema_name}.{table_name}.{load_id}.{file_id}.{ext}` to `{table_name}/{load_id}.{file_id}.{ext}` in dlt 0.3.12. You can revert to the old layout by setting it manually. +::: + +### Available layout placeholders + +#### Standard placeholders -> 💡 Note that the default layout format has changed from `{schema_name}.{table_name}.{load_id}.{file_id}.{ext}` to `{table_name}/{load_id}.{file_id}.{ext}` in dlt 0.3.12. You can revert to the old layout by setting the old value in your toml file. +* `schema_name` - the name of the [schema](../../general-usage/schema.md) +* `table_name` - table name +* `load_id` - the id of the [load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) from which the file comes from +* `file_id` - the id of the file, is there are many files with data for a single table, they are copied with different file ids +* `ext` - a format of the file i.e. `jsonl` or `parquet` +#### Date and time placeholders +:::tip +Keep in mind all values are lowercased. +::: + +* `timestamp` - the current timestamp in Unix Timestamp format rounded to minutes +* `load_package_timestamp` - timestamp from [load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) in Unix Timestamp format rounded to minutes +* Years + * `YYYY` - 2024, 2025 + * `Y` - 2024, 2025 +* Months + * `MMMM` - January, February, March + * `MMM` - Jan, Feb, Mar + * `MM` - 01, 02, 03 + * `M` - 1, 2, 3 +* Days of the month + * `DD` - 01, 02 + * `D` - 1, 2 +* Hours 24h format + * `HH` - 00, 01, 02...23 + * `H` - 0, 1, 2...23 +* Minutes + * `mm` - 00, 01, 02...59 + * `m` - 0, 1, 2...59 +* Days of the week + * `dddd` - Monday, Tuesday, Wednesday + * `ddd` - Mon, Tue, Wed + * `dd` - Mo, Tu, We + * `d` - 0-6 +* `Q` - quarters 1, 2, 3, 4, You can change the file name format by providing the layout setting for the filesystem destination like so: ```toml [destination.filesystem] layout="{table_name}/{load_id}.{file_id}.{ext}" # current preconfigured naming scheme -# layout="{schema_name}.{table_name}.{load_id}.{file_id}.{ext}" # naming scheme in dlt 0.3.11 and earlier + +# More examples +# With timestamp +# layout = "{table_name}/{timestamp}/{load_id}.{file_id}.{ext}" + +# With timestamp of the load package +# layout = "{table_name}/{load_package_timestamp}/{load_id}.{file_id}.{ext}" + +# Parquet-like layout (note: it is not compatible with the internal datetime of the parquet file) +# layout = "{table_name}/year={year}/month={month}/day={day}/{load_id}.{file_id}.{ext}" + +# Custom placeholders +# extra_placeholders = { "owner" = "admin", "department" = "finance" } +# layout = "{table_name}/{owner}/{department}/{load_id}.{file_id}.{ext}" ``` A few things to know when specifying your filename layout: - If you want a different base path that is common to all filenames, you can suffix your `bucket_url` rather than prefix your `layout` setting. -- If you do not provide the `{ext}` placeholder, it will automatically be added to your layout at the end with a dot as separator. -- It is the best practice to have a separator between each placeholder. Separators can be any character allowed as a filename character, but dots, dashes and forward slashes are most common. -- When you are using the `replace` disposition, `dlt`` will have to be able to figure out the correct files to delete before loading the new data. For this -to work, you have to +- If you do not provide the `{ext}` placeholder, it will automatically be added to your layout at the end with a dot as a separator. +- It is the best practice to have a separator between each placeholder. Separators can be any character allowed as a filename character, but dots, dashes, and forward slashes are most common. +- When you are using the `replace` disposition, `dlt` will have to be able to figure out the correct files to delete before loading the new data. For this to work, you have to - include the `{table_name}` placeholder in your layout - not have any other placeholders except for the `{schema_name}` placeholder before the table_name placeholder and - have a separator after the table_name placeholder @@ -241,6 +291,94 @@ Please note: - `dlt` will not dump the current schema content to the bucket - `dlt` will mark complete loads by creating an empty file that corresponds to `_dlt_loads` table. For example, if `chess._dlt_loads.1685299832` file is present in dataset folders, you can be sure that all files for the load package `1685299832` are completely loaded +### Advanced layout configuration + +The filesystem destination configuration supports advanced layout customization and the inclusion of additional placeholders. This can be done through `config.toml` or programmatically when initializing via a factory method. + +:::tip +For handling deeply nested layouts, consider enabling automatic directory creation for the local filesystem destination. This can be done by setting `kwargs = '{"auto_mkdir": true}'` to facilitate the creation of directories automatically. +::: + +#### Configuration via `config.toml` + +To configure the layout and placeholders using `config.toml`, use the following format: + +```toml +layout = "{table_name}/{test_placeholder}/{YYYY}-{MM}-{DD}/{ddd}/{mm}/{load_id}.{file_id}.{ext}" +extra_placeholders = { "test_placeholder" = "test_value" } +current_datetime="2024-04-14T00:00:00" +``` + +:::note +Ensure that the placeholder names match the intended usage. For example, `{test_placeholer}` should be corrected to `{test_placeholder}` for consistency. +::: + +#### Dynamic configuration in the code + +Configuration options, including layout and placeholders, can be overridden dynamically when initializing and passing the filesystem destination directly to the pipeline. + +```py +import pendulum + +import dlt +from dlt.destinations import filesystem + +pipeline = dlt.pipeline( + pipeline_name="data_things", + destination=filesystem( + layout="{table_name}/{test_placeholder}/{timestamp}/{load_id}.{file_id}.{ext}", + current_datetime=pendulum.now(), + extra_placeholders={ + "test_placeholder": "test_value", + } + ) +) +``` + +Furthermore, it is possible to + +1. Customize the behavior with callbacks for extra placeholder functionality. Each callback must accept the following positional arguments and return a string. +2. Customize the `current_datetime`, which can also be a callback function and expected to return a `pendulum.DateTime` instance. + +```py +import pendulum + +import dlt +from dlt.destinations import filesystem + +def placeholder_callback(schema_name: str, table_name: str, load_id: str, file_id: str, ext: str) -> str: + # Custom logic here + return "custom_value" + +def get_current_datetime() -> pendulum.DateTime: + return pendulum.now() + +pipeline = dlt.pipeline( + pipeline_name="data_things", + destination=filesystem( + layout="{table_name}/{placeholder_x}/{timestamp}/{load_id}.{file_id}.{ext}", + current_datetime=get_current_datetime, + extra_placeholders={ + "placeholder_x": placeholder_callback + } + ) +) +``` + +### Recommended layout + +The currently recommended layout structure is straightforward: + +```toml +layout="{table_name}/{load_id}.{file_id}.{ext}" +``` + +Adopting this layout offers several advantages: +1. **Efficiency:** it's fast and simple to process. +2. **Compatibility:** supports `replace` as the write disposition method. +3. **Flexibility:** compatible with various destinations, including Athena. +4. **Performance:** a deeply nested structure can slow down file navigation, whereas a simpler layout mitigates this issue. + ## Supported file formats You can choose the following file formats: * [jsonl](../file-formats/jsonl.md) is used by default @@ -250,6 +388,6 @@ You can choose the following file formats: ## Syncing of `dlt` state This destination does not support restoring the `dlt` state. You can change that by requesting the [feature](https://github.com/dlt-hub/dlt/issues/new/choose) or contributing to the core library 😄 -You can however easily [backup and restore the pipeline working folder](https://gist.github.com/rudolfix/ee6e16d8671f26ac4b9ffc915ad24b6e) - reusing the bucket and credentials used to store files. +You can, however, easily [backup and restore the pipeline working folder](https://gist.github.com/rudolfix/ee6e16d8671f26ac4b9ffc915ad24b6e) - reusing the bucket and credentials used to store files. - + \ No newline at end of file From 02daa10f5ef8feb1b3ab41a0f054a313f769ea2b Mon Sep 17 00:00:00 2001 From: David Scharf Date: Wed, 17 Apr 2024 19:29:10 +0200 Subject: [PATCH 17/41] filesystem state sync (#1184) * clean some stuff * first messy version of filesystem state sync * clean up a bit * fix bug in state sync * enable state tests for all bucket providers * do not store state to uninitialized dataset folders * fix linter errors * get current pipeline from pipeline context * fix bug in filesystem table init * update testing pipe * move away from "current" file, rather iterator bucket path contents * store pipeline state in load package state and send to filesystem destination from there * fix tests for changed number of files in filesystem destination * remove dev code * create init file also to mark datasets * fix tests to respect new init file change filesystem to fallback, to old state loading when used as staging destination * update filesystem docs * fix incoming tests of placeholders * small fixes * adds some tests for filesystem state also fixes table count loading to work for all bucket destinations * fix test helper * save schema with timestamp instead of load_id * pr fixes and move pipeline state saving to committing of extracted packages * ensure pipeline state is only saved to load package if it has changed * adds missing state injection into state package * fix athena iceberg locations * fix google drive filesystem with missing argument --- dlt/common/destination/reference.py | 5 +- dlt/common/storages/fsspecs/google_drive.py | 2 +- dlt/common/storages/load_package.py | 14 + .../impl/destination/destination.py | 4 +- dlt/destinations/impl/dummy/dummy.py | 4 +- .../impl/filesystem/filesystem.py | 241 ++++++++++++++++-- dlt/destinations/impl/qdrant/qdrant_client.py | 4 +- .../impl/weaviate/weaviate_client.py | 15 +- dlt/destinations/job_client_impl.py | 13 +- dlt/extract/extract.py | 25 +- dlt/load/load.py | 11 +- dlt/pipeline/pipeline.py | 18 +- dlt/pipeline/state_sync.py | 26 +- .../dlt-ecosystem/destinations/filesystem.md | 14 +- .../load/filesystem/test_filesystem_client.py | 11 + .../load/pipeline/test_filesystem_pipeline.py | 173 ++++++++++++- .../load/pipeline/test_replace_disposition.py | 30 +-- tests/load/pipeline/test_restore_state.py | 40 ++- tests/pipeline/utils.py | 57 ++--- 19 files changed, 568 insertions(+), 139 deletions(-) diff --git a/dlt/common/destination/reference.py b/dlt/common/destination/reference.py index 9318dca535..5422414cf3 100644 --- a/dlt/common/destination/reference.py +++ b/dlt/common/destination/reference.py @@ -273,7 +273,9 @@ def drop_storage(self) -> None: pass def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: """Updates storage to the current schema. @@ -434,6 +436,7 @@ def get_stored_schema(self) -> Optional[StorageSchemaInfo]: @abstractmethod def get_stored_schema_by_hash(self, version_hash: str) -> StorageSchemaInfo: + """retrieves the stored schema by hash""" pass @abstractmethod diff --git a/dlt/common/storages/fsspecs/google_drive.py b/dlt/common/storages/fsspecs/google_drive.py index 1be862668c..258a8622d1 100644 --- a/dlt/common/storages/fsspecs/google_drive.py +++ b/dlt/common/storages/fsspecs/google_drive.py @@ -237,7 +237,7 @@ def export(self, path: str, mime_type: str) -> Any: fileId=file_id, mimeType=mime_type, supportsAllDrives=True ).execute() - def ls(self, path: str, detail: Optional[bool] = False) -> Any: + def ls(self, path: str, detail: Optional[bool] = False, refresh: Optional[bool] = False) -> Any: """List files in a directory. Args: diff --git a/dlt/common/storages/load_package.py b/dlt/common/storages/load_package.py index 1c76fd39cd..1752039775 100644 --- a/dlt/common/storages/load_package.py +++ b/dlt/common/storages/load_package.py @@ -54,9 +54,23 @@ """Loader file formats with internal job types""" +class TPipelineStateDoc(TypedDict, total=False): + """Corresponds to the StateInfo Tuple""" + + version: int + engine_version: int + pipeline_name: str + state: str + version_hash: str + created_at: datetime.datetime + dlt_load_id: NotRequired[str] + + class TLoadPackageState(TVersionedState, total=False): created_at: DateTime """Timestamp when the load package was created""" + pipeline_state: NotRequired[TPipelineStateDoc] + """Pipeline state, added at the end of the extraction phase""" """A section of state that does not participate in change merging and version control""" destination_state: NotRequired[Dict[str, Any]] diff --git a/dlt/destinations/impl/destination/destination.py b/dlt/destinations/impl/destination/destination.py index 4d0f081aa6..69d1d1d98a 100644 --- a/dlt/destinations/impl/destination/destination.py +++ b/dlt/destinations/impl/destination/destination.py @@ -47,7 +47,9 @@ def drop_storage(self) -> None: pass def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: return super().update_stored_schema(only_tables, expected_update) diff --git a/dlt/destinations/impl/dummy/dummy.py b/dlt/destinations/impl/dummy/dummy.py index 47ae25828e..16affbc164 100644 --- a/dlt/destinations/impl/dummy/dummy.py +++ b/dlt/destinations/impl/dummy/dummy.py @@ -126,7 +126,9 @@ def drop_storage(self) -> None: pass def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: applied_update = super().update_stored_schema(only_tables, expected_update) if self.config.fail_schema_update: diff --git a/dlt/destinations/impl/filesystem/filesystem.py b/dlt/destinations/impl/filesystem/filesystem.py index f06cf5ae54..5dae4bf295 100644 --- a/dlt/destinations/impl/filesystem/filesystem.py +++ b/dlt/destinations/impl/filesystem/filesystem.py @@ -1,12 +1,17 @@ import posixpath import os +import base64 from types import TracebackType -from typing import ClassVar, List, Type, Iterable, Set, Iterator +from typing import ClassVar, List, Type, Iterable, Set, Iterator, Optional, Tuple, cast from fsspec import AbstractFileSystem from contextlib import contextmanager +from dlt.common import json, pendulum +from dlt.common.typing import DictStrAny + +import re import dlt -from dlt.common import logger +from dlt.common import logger, time from dlt.common.schema import Schema, TSchemaTables, TTableSchema from dlt.common.storages import FileStorage, fsspec_from_config from dlt.common.destination import DestinationCapabilitiesContext @@ -17,8 +22,12 @@ JobClientBase, FollowupJob, WithStagingDataset, + WithStateSync, + StorageSchemaInfo, + StateInfo, + DoNothingJob, ) - +from dlt.common.destination.exceptions import DestinationUndefinedEntity from dlt.destinations.job_impl import EmptyLoadJob from dlt.destinations.impl.filesystem import capabilities from dlt.destinations.impl.filesystem.configuration import FilesystemDestinationClientConfiguration @@ -26,6 +35,10 @@ from dlt.destinations import path_utils +INIT_FILE_NAME = "init" +FILENAME_SEPARATOR = "__" + + class LoadFilesystemJob(LoadJob): def __init__( self, @@ -86,7 +99,7 @@ def create_followup_jobs(self, final_state: TLoadJobState) -> List[NewLoadJob]: return jobs -class FilesystemClient(JobClientBase, WithStagingDataset): +class FilesystemClient(JobClientBase, WithStagingDataset, WithStateSync): """filesystem client storing jobs in memory""" capabilities: ClassVar[DestinationCapabilitiesContext] = capabilities() @@ -166,31 +179,56 @@ def initialize_storage(self, truncate_tables: Iterable[str] = None) -> None: " should be created previously!" ) + # we mark the storage folder as initialized + self.fs_client.makedirs(self.dataset_path, exist_ok=True) + self.fs_client.touch(posixpath.join(self.dataset_path, INIT_FILE_NAME)) + def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> TSchemaTables: # create destination dirs for all tables - dirs_to_create = self._get_table_dirs(only_tables or self.schema.tables.keys()) - for directory in dirs_to_create: + table_names = only_tables or self.schema.tables.keys() + dirs_to_create = self._get_table_dirs(table_names) + for tables_name, directory in zip(table_names, dirs_to_create): self.fs_client.makedirs(directory, exist_ok=True) + # we need to mark the folders of the data tables as initialized + if tables_name in self.schema.dlt_table_names(): + self.fs_client.touch(posixpath.join(directory, INIT_FILE_NAME)) + + # don't store schema when used as staging + if not self.config.as_staging: + self._store_current_schema() + return expected_update - def _get_table_dirs(self, table_names: Iterable[str]) -> Set[str]: - """Gets unique directories where table data is stored.""" - table_dirs: Set[str] = set() + def _get_table_dirs(self, table_names: Iterable[str]) -> List[str]: + """Gets directories where table data is stored.""" + table_dirs: List[str] = [] for table_name in table_names: - table_prefix = self.table_prefix_layout.format( - schema_name=self.schema.name, table_name=table_name - ) + # dlt tables do not respect layout (for now) + if table_name in self.schema.dlt_table_names(): + table_prefix = posixpath.join(table_name, "") + else: + table_prefix = self.table_prefix_layout.format( + schema_name=self.schema.name, table_name=table_name + ) destination_dir = posixpath.join(self.dataset_path, table_prefix) # extract the path component - table_dirs.add(os.path.dirname(destination_dir)) + table_dirs.append(posixpath.dirname(destination_dir)) return table_dirs def is_storage_initialized(self) -> bool: - return self.fs_client.isdir(self.dataset_path) # type: ignore[no-any-return] + return self.fs_client.exists(posixpath.join(self.dataset_path, INIT_FILE_NAME)) # type: ignore[no-any-return] def start_file_load(self, table: TTableSchema, file_path: str, load_id: str) -> LoadJob: + # skip the state table, we create a jsonl file in the complete_load step + # this does not apply to scenarios where we are using filesystem as staging + # where we want to load the state the regular way + if table["name"] == self.schema.state_table_name and not self.config.as_staging: + return DoNothingJob(file_path) + cls = FollowupFilesystemJob if self.config.as_staging else LoadFilesystemJob return cls( file_path, @@ -203,12 +241,6 @@ def start_file_load(self, table: TTableSchema, file_path: str, load_id: str) -> def restore_file_load(self, file_path: str) -> LoadJob: return EmptyLoadJob.from_file_path(file_path, "completed") - def complete_load(self, load_id: str) -> None: - schema_name = self.schema.name - table_name = self.schema.loads_table_name - file_name = f"{schema_name}.{table_name}.{load_id}" - self.fs_client.touch(posixpath.join(self.dataset_path, file_name)) - def __enter__(self) -> "FilesystemClient": return self @@ -219,3 +251,170 @@ def __exit__( def should_load_data_to_staging_dataset(self, table: TTableSchema) -> bool: return False + + # + # state stuff + # + + def _write_to_json_file(self, filepath: str, data: DictStrAny) -> None: + dirname = posixpath.dirname(filepath) + if not self.fs_client.isdir(dirname): + return + self.fs_client.write_text(filepath, json.dumps(data), "utf-8") + + def _to_path_safe_string(self, s: str) -> str: + """for base64 strings""" + return base64.b64decode(s).hex() if s else None + + def _list_dlt_dir(self, dirname: str) -> Iterator[Tuple[str, List[str]]]: + if not self.fs_client.exists(posixpath.join(dirname, INIT_FILE_NAME)): + raise DestinationUndefinedEntity({"dir": dirname}) + for filepath in self.fs_client.ls(dirname, detail=False, refresh=True): + filename = os.path.splitext(os.path.basename(filepath))[0] + fileparts = filename.split(FILENAME_SEPARATOR) + if len(fileparts) != 3: + continue + yield filepath, fileparts + + def _store_load(self, load_id: str) -> None: + # write entry to load "table" + # TODO: this is also duplicate across all destinations. DRY this. + load_data = { + "load_id": load_id, + "schema_name": self.schema.name, + "status": 0, + "inserted_at": pendulum.now().isoformat(), + "schema_version_hash": self.schema.version_hash, + } + filepath = posixpath.join( + self.dataset_path, + self.schema.loads_table_name, + f"{self.schema.name}{FILENAME_SEPARATOR}{load_id}.jsonl", + ) + self._write_to_json_file(filepath, load_data) + + def complete_load(self, load_id: str) -> None: + # store current state + self._store_current_state(load_id) + self._store_load(load_id) + + # + # state read/write + # + + def _get_state_file_name(self, pipeline_name: str, version_hash: str, load_id: str) -> str: + """gets full path for schema file for a given hash""" + return posixpath.join( + self.dataset_path, + self.schema.state_table_name, + f"{pipeline_name}{FILENAME_SEPARATOR}{load_id}{FILENAME_SEPARATOR}{self._to_path_safe_string(version_hash)}.jsonl", + ) + + def _store_current_state(self, load_id: str) -> None: + # don't save the state this way when used as staging + if self.config.as_staging: + return + # get state doc from current pipeline + from dlt.pipeline.current import load_package + + pipeline_state_doc = load_package()["state"].get("pipeline_state") + + if not pipeline_state_doc: + return + + # get paths + pipeline_name = pipeline_state_doc["pipeline_name"] + hash_path = self._get_state_file_name( + pipeline_name, self.schema.stored_version_hash, load_id + ) + + # write + self._write_to_json_file(hash_path, cast(DictStrAny, pipeline_state_doc)) + + def get_stored_state(self, pipeline_name: str) -> Optional[StateInfo]: + # get base dir + dirname = posixpath.dirname(self._get_state_file_name(pipeline_name, "", "")) + + # search newest state + selected_path = None + newest_load_id = "0" + for filepath, fileparts in self._list_dlt_dir(dirname): + if fileparts[0] == pipeline_name and fileparts[1] > newest_load_id: + newest_load_id = fileparts[1] + selected_path = filepath + + # Load compressed state from destination + if selected_path: + state_json = json.loads(self.fs_client.read_text(selected_path)) + state_json.pop("version_hash") + return StateInfo(**state_json) + + return None + + # + # Schema read/write + # + + def _get_schema_file_name(self, version_hash: str, load_id: str) -> str: + """gets full path for schema file for a given hash""" + return posixpath.join( + self.dataset_path, + self.schema.version_table_name, + f"{self.schema.name}{FILENAME_SEPARATOR}{load_id}{FILENAME_SEPARATOR}{self._to_path_safe_string(version_hash)}.jsonl", + ) + + def _get_stored_schema_by_hash_or_newest( + self, version_hash: str = None + ) -> Optional[StorageSchemaInfo]: + """Get the schema by supplied hash, falls back to getting the newest version matching the existing schema name""" + version_hash = self._to_path_safe_string(version_hash) + dirname = posixpath.dirname(self._get_schema_file_name("", "")) + # find newest schema for pipeline or by version hash + selected_path = None + newest_load_id = "0" + for filepath, fileparts in self._list_dlt_dir(dirname): + if ( + not version_hash + and fileparts[0] == self.schema.name + and fileparts[1] > newest_load_id + ): + newest_load_id = fileparts[1] + selected_path = filepath + elif fileparts[2] == version_hash: + selected_path = filepath + break + + if selected_path: + return StorageSchemaInfo(**json.loads(self.fs_client.read_text(selected_path))) + + return None + + def _store_current_schema(self) -> None: + # check if schema with hash exists + current_hash = self.schema.stored_version_hash + if self._get_stored_schema_by_hash_or_newest(current_hash): + return + + # get paths + schema_id = str(time.precise_time()) + filepath = self._get_schema_file_name(self.schema.stored_version_hash, schema_id) + + # TODO: duplicate of weaviate implementation, should be abstracted out + version_info = { + "version_hash": self.schema.stored_version_hash, + "schema_name": self.schema.name, + "version": self.schema.version, + "engine_version": self.schema.ENGINE_VERSION, + "inserted_at": pendulum.now(), + "schema": json.dumps(self.schema.to_dict()), + } + + # we always keep tabs on what the current schema is + self._write_to_json_file(filepath, version_info) + + def get_stored_schema(self) -> Optional[StorageSchemaInfo]: + """Retrieves newest schema from destination storage""" + return self._get_stored_schema_by_hash_or_newest() + + def get_stored_schema_by_hash(self, version_hash: str) -> Optional[StorageSchemaInfo]: + return self._get_stored_schema_by_hash_or_newest(version_hash) diff --git a/dlt/destinations/impl/qdrant/qdrant_client.py b/dlt/destinations/impl/qdrant/qdrant_client.py index 5a5e5f8cfd..9898b28c86 100644 --- a/dlt/destinations/impl/qdrant/qdrant_client.py +++ b/dlt/destinations/impl/qdrant/qdrant_client.py @@ -283,7 +283,9 @@ def _delete_sentinel_collection(self) -> None: self.db_client.delete_collection(self.sentinel_collection) def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: super().update_stored_schema(only_tables, expected_update) applied_update: TSchemaTables = {} diff --git a/dlt/destinations/impl/weaviate/weaviate_client.py b/dlt/destinations/impl/weaviate/weaviate_client.py index ab2bea54ef..2d75ca0809 100644 --- a/dlt/destinations/impl/weaviate/weaviate_client.py +++ b/dlt/destinations/impl/weaviate/weaviate_client.py @@ -424,7 +424,9 @@ def _delete_sentinel_class(self) -> None: @wrap_weaviate_error def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: super().update_stored_schema(only_tables, expected_update) # Retrieve the schema from Weaviate @@ -524,17 +526,6 @@ def get_stored_state(self, pipeline_name: str) -> Optional[StateInfo]: state["dlt_load_id"] = state.pop("_dlt_load_id") return StateInfo(**state) - # def get_stored_states(self, state_table: str) -> List[StateInfo]: - # state_records = self.get_records(state_table, - # sort={ - # "path": ["created_at"], - # "order": "desc" - # }, properties=self.state_properties) - - # for state in state_records: - # state["dlt_load_id"] = state.pop("_dlt_load_id") - # return [StateInfo(**state) for state in state_records] - def get_stored_schema(self) -> Optional[StorageSchemaInfo]: """Retrieves newest schema from destination storage""" try: diff --git a/dlt/destinations/job_client_impl.py b/dlt/destinations/job_client_impl.py index 7f1403eb30..5838ab2ab7 100644 --- a/dlt/destinations/job_client_impl.py +++ b/dlt/destinations/job_client_impl.py @@ -180,7 +180,9 @@ def is_storage_initialized(self) -> bool: return self.sql_client.has_dataset() def update_stored_schema( - self, only_tables: Iterable[str] = None, expected_update: TSchemaTables = None + self, + only_tables: Iterable[str] = None, + expected_update: TSchemaTables = None, ) -> Optional[TSchemaTables]: super().update_stored_schema(only_tables, expected_update) applied_update: TSchemaTables = {} @@ -373,15 +375,6 @@ def get_stored_state(self, pipeline_name: str) -> StateInfo: return None return StateInfo(row[0], row[1], row[2], row[3], pendulum.instance(row[4])) - # def get_stored_states(self, state_table: str) -> List[StateInfo]: - # """Loads list of compressed states from destination storage, optionally filtered by pipeline name""" - # query = f"SELECT {self.STATE_TABLE_COLUMNS} FROM {state_table} AS s ORDER BY created_at DESC" - # result: List[StateInfo] = [] - # with self.sql_client.execute_query(query) as cur: - # for row in cur.fetchall(): - # result.append(StateInfo(row[0], row[1], row[2], row[3], pendulum.instance(row[4]))) - # return result - def get_stored_schema_by_hash(self, version_hash: str) -> StorageSchemaInfo: name = self.sql_client.make_qualified_table_name(self.schema.version_table_name) query = f"SELECT {self.version_table_schema_columns} FROM {name} WHERE version_hash = %s;" diff --git a/dlt/extract/extract.py b/dlt/extract/extract.py index cc2b03c50b..d4298f2f6b 100644 --- a/dlt/extract/extract.py +++ b/dlt/extract/extract.py @@ -17,6 +17,7 @@ WithStepInfo, reset_resource_state, ) +from dlt.common.typing import DictStrAny from dlt.common.runtime import signals from dlt.common.runtime.collector import Collector, NULL_COLLECTOR from dlt.common.schema import Schema, utils @@ -27,7 +28,13 @@ TWriteDispositionConfig, ) from dlt.common.storages import NormalizeStorageConfiguration, LoadPackageInfo, SchemaStorage -from dlt.common.storages.load_package import ParsedLoadJobFileName +from dlt.common.storages.load_package import ( + ParsedLoadJobFileName, + LoadPackageStateInjectableContext, + TPipelineStateDoc, +) + + from dlt.common.utils import get_callable_name, get_full_class_name from dlt.extract.decorators import SourceInjectableContext, SourceSchemaInjectableContext @@ -367,7 +374,13 @@ def extract( load_id = self.extract_storage.create_load_package(source.discover_schema()) with Container().injectable_context( SourceSchemaInjectableContext(source.schema) - ), Container().injectable_context(SourceInjectableContext(source)): + ), Container().injectable_context( + SourceInjectableContext(source) + ), Container().injectable_context( + LoadPackageStateInjectableContext( + storage=self.extract_storage.new_packages, load_id=load_id + ) + ): # inject the config section with the current source name with inject_section( ConfigSectionContext( @@ -389,10 +402,14 @@ def extract( ) return load_id - def commit_packages(self) -> None: - """Commits all extracted packages to normalize storage""" + def commit_packages(self, pipline_state_doc: TPipelineStateDoc = None) -> None: + """Commits all extracted packages to normalize storage, and adds the pipeline state to the load package""" # commit load packages for load_id, metrics in self._load_id_metrics.items(): + if pipline_state_doc: + package_state = self.extract_storage.new_packages.get_load_package_state(load_id) + package_state["pipeline_state"] = {**pipline_state_doc, "dlt_load_id": load_id} + self.extract_storage.new_packages.save_load_package_state(load_id, package_state) self.extract_storage.commit_new_load_package( load_id, self.schema_storage[metrics[0]["schema_name"]] ) diff --git a/dlt/load/load.py b/dlt/load/load.py index c5790d467b..66ddb1c308 100644 --- a/dlt/load/load.py +++ b/dlt/load/load.py @@ -341,7 +341,13 @@ def complete_package(self, load_id: str, schema: Schema, aborted: bool = False) # do not commit load id for aborted packages if not aborted: with self.get_destination_client(schema) as job_client: - job_client.complete_load(load_id) + with Container().injectable_context( + LoadPackageStateInjectableContext( + storage=self.load_storage.normalized_packages, + load_id=load_id, + ) + ): + job_client.complete_load(load_id) self.load_storage.complete_load_package(load_id, aborted) # collect package info self._loaded_packages.append(self.load_storage.get_load_package_info(load_id)) @@ -469,10 +475,9 @@ def run(self, pool: Optional[Executor]) -> TRunMetrics: schema = self.load_storage.normalized_packages.load_schema(load_id) logger.info(f"Loaded schema name {schema.name} and version {schema.stored_version}") - container = Container() # get top load id and mark as being processed with self.collector(f"Load {schema.name} in {load_id}"): - with container.injectable_context( + with Container().injectable_context( LoadPackageStateInjectableContext( storage=self.load_storage.normalized_packages, load_id=load_id, diff --git a/dlt/pipeline/pipeline.py b/dlt/pipeline/pipeline.py index ebff4dfa1d..e1821a9ac8 100644 --- a/dlt/pipeline/pipeline.py +++ b/dlt/pipeline/pipeline.py @@ -63,6 +63,7 @@ LoadJobInfo, LoadPackageInfo, ) +from dlt.common.storages.load_package import TPipelineStateDoc from dlt.common.destination import ( DestinationCapabilitiesContext, merge_caps_file_formats, @@ -138,6 +139,7 @@ mark_state_extracted, migrate_pipeline_state, state_resource, + state_doc, default_pipeline_state, ) from dlt.pipeline.warnings import credentials_argument_deprecated @@ -427,13 +429,14 @@ def extract( raise SourceExhausted(source.name) self._extract_source(extract_step, source, max_parallel_items, workers) # extract state + state: TPipelineStateDoc = None if self.config.restore_from_destination: # this will update state version hash so it will not be extracted again by with_state_sync - self._bump_version_and_extract_state( + state = self._bump_version_and_extract_state( self._container[StateInjectableContext].state, True, extract_step ) - # commit load packages - extract_step.commit_packages() + # commit load packages with state + extract_step.commit_packages(state) return self._get_step_info(extract_step) except Exception as exc: # emit step info @@ -728,7 +731,6 @@ def sync_destination( remote_state["schema_names"], always_download=True ) # TODO: we should probably wipe out pipeline here - # if we didn't full refresh schemas, get only missing schemas if restored_schemas is None: restored_schemas = self._get_schemas_from_destination( @@ -1513,7 +1515,7 @@ def _props_to_state(self, state: TPipelineState) -> TPipelineState: def _bump_version_and_extract_state( self, state: TPipelineState, extract_state: bool, extract: Extract = None - ) -> None: + ) -> TPipelineStateDoc: """Merges existing state into `state` and extracts state using `storage` if extract_state is True. Storage will be created on demand. In that case the extracted package will be immediately committed. @@ -1521,7 +1523,7 @@ def _bump_version_and_extract_state( _, hash_, _ = bump_pipeline_state_version_if_modified(self._props_to_state(state)) should_extract = hash_ != state["_local"].get("_last_extracted_hash") if should_extract and extract_state: - data = state_resource(state) + data, doc = state_resource(state) extract_ = extract or Extract( self._schema_storage, self._normalize_storage_config(), original_data=data ) @@ -1532,7 +1534,9 @@ def _bump_version_and_extract_state( mark_state_extracted(state, hash_) # commit only if we created storage if not extract: - extract_.commit_packages() + extract_.commit_packages(doc) + return doc + return None def _list_schemas_sorted(self) -> List[str]: """Lists schema names sorted to have deterministic state""" diff --git a/dlt/pipeline/state_sync.py b/dlt/pipeline/state_sync.py index d38010f842..41009f2909 100644 --- a/dlt/pipeline/state_sync.py +++ b/dlt/pipeline/state_sync.py @@ -5,7 +5,7 @@ from dlt.common.pendulum import pendulum from dlt.common.typing import DictStrAny from dlt.common.schema.typing import STATE_TABLE_NAME, TTableSchemaColumns -from dlt.common.destination.reference import WithStateSync, Destination +from dlt.common.destination.reference import WithStateSync, Destination, StateInfo from dlt.common.versioned_state import ( generate_state_version_hash, bump_state_version_if_modified, @@ -14,7 +14,7 @@ decompress_state, ) from dlt.common.pipeline import TPipelineState - +from dlt.common.storages.load_package import TPipelineStateDoc from dlt.extract import DltResource from dlt.pipeline.exceptions import ( @@ -22,6 +22,7 @@ ) PIPELINE_STATE_ENGINE_VERSION = 4 +LOAD_PACKAGE_STATE_KEY = "pipeline_state" # state table columns STATE_TABLE_COLUMNS: TTableSchemaColumns = { @@ -93,11 +94,11 @@ def migrate_pipeline_state( return cast(TPipelineState, state) -def state_resource(state: TPipelineState) -> DltResource: +def state_doc(state: TPipelineState, load_id: str = None) -> TPipelineStateDoc: state = copy(state) state.pop("_local") state_str = compress_state(state) - state_doc = { + doc: TPipelineStateDoc = { "version": state["_state_version"], "engine_version": state["_state_engine_version"], "pipeline_name": state["pipeline_name"], @@ -105,8 +106,21 @@ def state_resource(state: TPipelineState) -> DltResource: "created_at": pendulum.now(), "version_hash": state["_version_hash"], } - return dlt.resource( - [state_doc], name=STATE_TABLE_NAME, write_disposition="append", columns=STATE_TABLE_COLUMNS + if load_id: + doc["dlt_load_id"] = load_id + return doc + + +def state_resource(state: TPipelineState) -> Tuple[DltResource, TPipelineStateDoc]: + doc = state_doc(state) + return ( + dlt.resource( + [doc], + name=STATE_TABLE_NAME, + write_disposition="append", + columns=STATE_TABLE_COLUMNS, + ), + doc, ) diff --git a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md index 3124026bd5..76e0108461 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md +++ b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md @@ -288,8 +288,7 @@ A few things to know when specifying your filename layout: - have a separator after the table_name placeholder Please note: -- `dlt` will not dump the current schema content to the bucket -- `dlt` will mark complete loads by creating an empty file that corresponds to `_dlt_loads` table. For example, if `chess._dlt_loads.1685299832` file is present in dataset folders, you can be sure that all files for the load package `1685299832` are completely loaded +- `dlt` will mark complete loads by creating a json file in the `./_dlt_loads` folders that corresponds to the`_dlt_loads` table. For example, if `chess__1685299832.jsonl` file is present in the loads folder, you can be sure that all files for the load package `1685299832` are completely loaded ### Advanced layout configuration @@ -387,7 +386,14 @@ You can choose the following file formats: ## Syncing of `dlt` state -This destination does not support restoring the `dlt` state. You can change that by requesting the [feature](https://github.com/dlt-hub/dlt/issues/new/choose) or contributing to the core library 😄 -You can, however, easily [backup and restore the pipeline working folder](https://gist.github.com/rudolfix/ee6e16d8671f26ac4b9ffc915ad24b6e) - reusing the bucket and credentials used to store files. +This destination fully supports [dlt state sync](../../general-usage/state#syncing-state-with-destination). To this end, special folders and files that will be created at your destination which hold information about your pipeline state, schemas and completed loads. These folders DO NOT respect your +settings in the layout section. When using filesystem as a staging destination, not all of these folders are created, as the state and schemas are +managed in the regular way by the final destination you have configured. + +You will also notice `init` files being present in the root folder and the special `dlt` folders. In the absence of the concepts of schemas and tables +in blob storages and directories, `dlt` uses these special files to harmonize the behavior of the `filesystem` destination with the other implemented destinations. + + + \ No newline at end of file diff --git a/tests/load/filesystem/test_filesystem_client.py b/tests/load/filesystem/test_filesystem_client.py index 5d2404ff48..41b98bdec3 100644 --- a/tests/load/filesystem/test_filesystem_client.py +++ b/tests/load/filesystem/test_filesystem_client.py @@ -9,6 +9,7 @@ from dlt.destinations.impl.filesystem.filesystem import ( FilesystemDestinationClientConfiguration, + INIT_FILE_NAME, ) from dlt.destinations.path_utils import create_path, prepare_datetime_params @@ -155,7 +156,12 @@ def test_replace_write_disposition(layout: str, default_buckets_env: str) -> Non for basedir, _dirs, files in client.fs_client.walk( client.dataset_path, detail=False, refresh=True ): + # remove internal paths + if "_dlt" in basedir: + continue for f in files: + if f == INIT_FILE_NAME: + continue paths.append(posixpath.join(basedir, f)) ls = set(paths) @@ -213,6 +219,11 @@ def test_append_write_disposition(layout: str, default_buckets_env: str) -> None for basedir, _dirs, files in client.fs_client.walk( client.dataset_path, detail=False, refresh=True ): + # remove internal paths + if "_dlt" in basedir: + continue for f in files: + if f == INIT_FILE_NAME: + continue paths.append(posixpath.join(basedir, f)) assert list(sorted(paths)) == expected_files diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index d24b799349..20f326b160 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -2,7 +2,7 @@ import os import posixpath from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, List, Dict, cast import dlt import pytest @@ -19,6 +19,11 @@ from tests.common.utils import load_json_case from tests.utils import ALL_TEST_DATA_ITEM_FORMATS, TestDataItemFormat, skip_if_not_active from dlt.destinations.path_utils import create_path +from tests.load.pipeline.utils import ( + destinations_configs, + DestinationTestConfiguration, + load_table_counts, +) skip_if_not_active("filesystem") @@ -98,11 +103,15 @@ def some_source(): for job in pkg.jobs["completed_jobs"]: assert_file_matches(layout, job, pkg.load_id, client) - complete_fn = f"{client.schema.name}.{LOADS_TABLE_NAME}.%s" + complete_fn = f"{client.schema.name}__%s.jsonl" # Test complete_load markers are saved - assert client.fs_client.isfile(posixpath.join(client.dataset_path, complete_fn % load_id1)) - assert client.fs_client.isfile(posixpath.join(client.dataset_path, complete_fn % load_id2)) + assert client.fs_client.isfile( + posixpath.join(client.dataset_path, client.schema.loads_table_name, complete_fn % load_id1) + ) + assert client.fs_client.isfile( + posixpath.join(client.dataset_path, client.schema.loads_table_name, complete_fn % load_id2) + ) # Force replace pipeline.run(some_source(), write_disposition="replace") @@ -244,13 +253,19 @@ def count(*args, **kwargs) -> Any: expected_files = set() known_files = set() for basedir, _dirs, files in client.fs_client.walk(client.dataset_path): # type: ignore[attr-defined] + # strip out special tables + if "_dlt" in basedir: + continue for file in files: - if file.endswith("jsonl"): + if ".jsonl" in file: expected_files.add(os.path.join(basedir, file)) for load_package in load_info.load_packages: for load_info in load_package.jobs["completed_jobs"]: # type: ignore[assignment] job_info = ParsedLoadJobFileName.parse(load_info.file_path) # type: ignore[attr-defined] + # state file gets loaded a differentn way + if job_info.table_name == "_dlt_pipeline_state": + continue path = create_path( layout, file_name=job_info.file_name(), @@ -262,10 +277,156 @@ def count(*args, **kwargs) -> Any: ) full_path = os.path.join(client.dataset_path, path) # type: ignore[attr-defined] assert os.path.exists(full_path) - if full_path.endswith("jsonl"): + if ".jsonl" in full_path: known_files.add(full_path) assert expected_files == known_files + assert known_files # 6 is because simple_row contains two rows # and in this test scenario we have 3 callbacks assert call_count >= 6 + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(all_buckets_filesystem_configs=True), + ids=lambda x: x.name, +) +def test_state_files(destination_config: DestinationTestConfiguration) -> None: + def _collect_files(p) -> List[str]: + client = p.destination_client() + found = [] + for basedir, _dirs, files in client.fs_client.walk(client.dataset_path): + for file in files: + found.append(os.path.join(basedir, file).replace(client.dataset_path, "")) + return found + + def _collect_table_counts(p) -> Dict[str, int]: + return load_table_counts( + p, "items", "items2", "items3", "_dlt_loads", "_dlt_version", "_dlt_pipeline_state" + ) + + # generate 4 loads from 2 pipelines, store load ids + p1 = destination_config.setup_pipeline("p1", dataset_name="layout_test") + p2 = destination_config.setup_pipeline("p2", dataset_name="layout_test") + c1 = cast(FilesystemClient, p1.destination_client()) + c2 = cast(FilesystemClient, p2.destination_client()) + + # first two loads + p1.run([1, 2, 3], table_name="items").loads_ids[0] + load_id_2_1 = p2.run([4, 5, 6], table_name="items").loads_ids[0] + assert _collect_table_counts(p1) == { + "items": 6, + "_dlt_loads": 2, + "_dlt_pipeline_state": 2, + "_dlt_version": 2, + } + sc1_old = c1.get_stored_schema() + sc2_old = c2.get_stored_schema() + s1_old = c1.get_stored_state("p1") + s2_old = c1.get_stored_state("p2") + + created_files = _collect_files(p1) + # 4 init files, 2 item files, 2 load files, 2 state files, 2 version files + assert len(created_files) == 12 + + # second two loads + @dlt.resource(table_name="items2") + def some_data(): + dlt.current.resource_state()["state"] = {"some": "state"} + yield from [1, 2, 3] + + load_id_1_2 = p1.run(some_data(), table_name="items2").loads_ids[ + 0 + ] # force state and migration bump here + p2.run([4, 5, 6], table_name="items").loads_ids[0] # no migration here + + # 4 loads for 2 pipelines, one schema and state change on p2 changes so 3 versions and 3 states + assert _collect_table_counts(p1) == { + "items": 9, + "items2": 3, + "_dlt_loads": 4, + "_dlt_pipeline_state": 3, + "_dlt_version": 3, + } + + # test accessors for state + s1 = c1.get_stored_state("p1") + s2 = c1.get_stored_state("p2") + assert s1.dlt_load_id == load_id_1_2 # second load + assert s2.dlt_load_id == load_id_2_1 # first load + assert s1_old.version != s1.version + assert s2_old.version == s2.version + + # test accessors for schema + sc1 = c1.get_stored_schema() + sc2 = c2.get_stored_schema() + assert sc1.version_hash != sc1_old.version_hash + assert sc2.version_hash == sc2_old.version_hash + assert sc1.version_hash != sc2.version_hash + + assert not c1.get_stored_schema_by_hash("blah") + assert c2.get_stored_schema_by_hash(sc1_old.version_hash) + + created_files = _collect_files(p1) + # 4 init files, 4 item files, 4 load files, 3 state files, 3 version files + assert len(created_files) == 18 + + # drop it + p1.destination_client().drop_storage() + created_files = _collect_files(p1) + assert len(created_files) == 0 + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(all_buckets_filesystem_configs=True), + ids=lambda x: x.name, +) +def test_knows_dataset_state(destination_config: DestinationTestConfiguration) -> None: + # check if pipeline knows initializisation state of dataset + p1 = destination_config.setup_pipeline("p1", dataset_name="layout_test") + assert not p1.destination_client().is_storage_initialized() + p1.run([1, 2, 3], table_name="items") + assert p1.destination_client().is_storage_initialized() + p1.destination_client().drop_storage() + assert not p1.destination_client().is_storage_initialized() + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(all_buckets_filesystem_configs=True), + ids=lambda x: x.name, +) +@pytest.mark.parametrize("restore", [True, False]) +def test_simple_incremental( + destination_config: DestinationTestConfiguration, + restore: bool, +) -> None: + os.environ["RESTORE_FROM_DESTINATION"] = str(restore) + + p = destination_config.setup_pipeline("p1", dataset_name="incremental_test") + + @dlt.resource(name="items") + def my_resource(prim_key=dlt.sources.incremental("id")): + yield from [ + {"id": 1}, + {"id": 2}, + ] + + @dlt.resource(name="items") + def my_resource_inc(prim_key=dlt.sources.incremental("id")): + yield from [ + {"id": 1}, + {"id": 2}, + {"id": 3}, + {"id": 4}, + ] + + p.run(my_resource) + p._wipe_working_folder() + + p = destination_config.setup_pipeline("p1", dataset_name="incremental_test") + p.run(my_resource_inc) + + assert load_table_counts(p, "items") == {"items": 4 if restore else 6} diff --git a/tests/load/pipeline/test_replace_disposition.py b/tests/load/pipeline/test_replace_disposition.py index 6efde6e019..09a746433f 100644 --- a/tests/load/pipeline/test_replace_disposition.py +++ b/tests/load/pipeline/test_replace_disposition.py @@ -41,8 +41,6 @@ def test_replace_disposition( # make duckdb to reuse database in working folder os.environ["DESTINATION__DUCKDB__CREDENTIALS"] = "duckdb:///test_replace_disposition.duckdb" - # TODO: start storing _dlt_loads with right json content - increase_loads = lambda x: x if destination_config.destination == "filesystem" else x + 1 increase_state_loads = lambda info: len( [ job @@ -52,11 +50,9 @@ def test_replace_disposition( ] ) - # filesystem does not have versions and child tables + # filesystem does not have child tables, prepend defaults def norm_table_counts(counts: Dict[str, int], *child_tables: str) -> Dict[str, int]: - if destination_config.destination != "filesystem": - return counts - return {**{"_dlt_version": 0}, **{t: 0 for t in child_tables}, **counts} + return {**{t: 0 for t in child_tables}, **counts} dataset_name = "test_replace_strategies_ds" + uniq_id() pipeline = destination_config.setup_pipeline( @@ -108,8 +104,8 @@ def append_items(): assert_load_info(info) # count state records that got extracted state_records = increase_state_loads(info) - dlt_loads: int = increase_loads(0) - dlt_versions: int = increase_loads(0) + dlt_loads: int = 1 + dlt_versions: int = 1 # second run with higher offset so we can check the results offset = 1000 @@ -118,11 +114,11 @@ def append_items(): ) assert_load_info(info) state_records += increase_state_loads(info) - dlt_loads = increase_loads(dlt_loads) + dlt_loads += 1 # we should have all items loaded table_counts = load_table_counts(pipeline, *pipeline.default_schema.tables.keys()) - assert norm_table_counts(table_counts) == { + assert table_counts == { "append_items": 24, # loaded twice "items": 120, "items__sub_items": 240, @@ -166,7 +162,7 @@ def load_items_none(): ) assert_load_info(info) state_records += increase_state_loads(info) - dlt_loads = increase_loads(dlt_loads) + dlt_loads += 1 # table and child tables should be cleared table_counts = load_table_counts(pipeline, *pipeline.default_schema.tables.keys()) @@ -200,8 +196,8 @@ def load_items_none(): assert_load_info(info) new_state_records = increase_state_loads(info) assert new_state_records == 1 - dlt_loads = increase_loads(dlt_loads) - dlt_versions = increase_loads(dlt_versions) + dlt_loads += 1 + dlt_versions += 1 # check trace assert pipeline_2.last_trace.last_normalize_info.row_counts == { "items_copy": 120, @@ -214,18 +210,18 @@ def load_items_none(): assert_load_info(info) new_state_records = increase_state_loads(info) assert new_state_records == 0 - dlt_loads = increase_loads(dlt_loads) + dlt_loads += 1 # new pipeline table_counts = load_table_counts(pipeline_2, *pipeline_2.default_schema.tables.keys()) - assert norm_table_counts(table_counts) == { + assert table_counts == { "append_items": 48, "items_copy": 120, "items_copy__sub_items": 240, "items_copy__sub_items__sub_sub_items": 120, "_dlt_pipeline_state": state_records + 1, "_dlt_loads": dlt_loads, - "_dlt_version": increase_loads(dlt_versions), + "_dlt_version": dlt_versions + 1, } # check trace assert pipeline_2.last_trace.last_normalize_info.row_counts == { @@ -243,7 +239,7 @@ def load_items_none(): "items__sub_items__sub_sub_items": 0, "_dlt_pipeline_state": state_records + 1, "_dlt_loads": dlt_loads, # next load - "_dlt_version": increase_loads(dlt_versions), # new table name -> new schema + "_dlt_version": dlt_versions + 1, # new table name -> new schema } diff --git a/tests/load/pipeline/test_restore_state.py b/tests/load/pipeline/test_restore_state.py index d421819121..6518ca46ae 100644 --- a/tests/load/pipeline/test_restore_state.py +++ b/tests/load/pipeline/test_restore_state.py @@ -43,7 +43,10 @@ def duckdb_pipeline_location() -> None: @pytest.mark.parametrize( "destination_config", destinations_configs( - default_staging_configs=True, default_sql_configs=True, default_vector_configs=True + default_staging_configs=True, + default_sql_configs=True, + default_vector_configs=True, + all_buckets_filesystem_configs=True, ), ids=lambda x: x.name, ) @@ -62,8 +65,9 @@ def test_restore_state_utils(destination_config: DestinationTestConfiguration) - load_pipeline_state_from_destination(p.pipeline_name, job_client) # sync the schema p.sync_schema() - exists, _ = job_client.get_storage_table(schema.version_table_name) - assert exists is True + # check if schema exists + stored_schema = job_client.get_stored_schema() + assert stored_schema is not None # dataset exists, still no table with pytest.raises(DestinationUndefinedEntity): load_pipeline_state_from_destination(p.pipeline_name, job_client) @@ -72,7 +76,7 @@ def test_restore_state_utils(destination_config: DestinationTestConfiguration) - initial_state["_local"]["_last_extracted_at"] = pendulum.now() initial_state["_local"]["_last_extracted_hash"] = initial_state["_version_hash"] # add _dlt_id and _dlt_load_id - resource = state_resource(initial_state) + resource, _ = state_resource(initial_state) resource.apply_hints( columns={ "_dlt_id": {"name": "_dlt_id", "data_type": "text", "nullable": False}, @@ -86,8 +90,8 @@ def test_restore_state_utils(destination_config: DestinationTestConfiguration) - # then in database. parquet is created in schema order and in Redshift it must exactly match the order. # schema.bump_version() p.sync_schema() - exists, _ = job_client.get_storage_table(schema.state_table_name) - assert exists is True + stored_schema = job_client.get_stored_schema() + assert stored_schema is not None # table is there but no state assert load_pipeline_state_from_destination(p.pipeline_name, job_client) is None # extract state @@ -180,7 +184,9 @@ def test_silently_skip_on_invalid_credentials( @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) @pytest.mark.parametrize("use_single_dataset", [True, False]) @@ -263,7 +269,9 @@ def _make_dn_name(schema_name: str) -> str: @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) def test_restore_state_pipeline(destination_config: DestinationTestConfiguration) -> None: @@ -387,7 +395,9 @@ def some_data(): @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) def test_ignore_state_unfinished_load(destination_config: DestinationTestConfiguration) -> None: @@ -417,7 +427,9 @@ def complete_package_mock(self, load_id: str, schema: Schema, aborted: bool = Fa @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) def test_restore_schemas_while_import_schemas_exist( @@ -503,7 +515,9 @@ def test_restore_change_dataset_and_destination(destination_name: str) -> None: @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) def test_restore_state_parallel_changes(destination_config: DestinationTestConfiguration) -> None: @@ -609,7 +623,9 @@ def some_data(param: str) -> Any: @pytest.mark.parametrize( "destination_config", - destinations_configs(default_sql_configs=True, default_vector_configs=True), + destinations_configs( + default_sql_configs=True, default_vector_configs=True, all_buckets_filesystem_configs=True + ), ids=lambda x: x.name, ) def test_reset_pipeline_on_deleted_dataset( diff --git a/tests/pipeline/utils.py b/tests/pipeline/utils.py index c4e1f5314b..036154b582 100644 --- a/tests/pipeline/utils.py +++ b/tests/pipeline/utils.py @@ -3,6 +3,7 @@ import pytest import random from os import environ +import io import dlt from dlt.common import json, sleep @@ -80,7 +81,7 @@ def assert_data_table_counts(p: dlt.Pipeline, expected_counts: DictStrAny) -> No ), f"Table counts do not match, expected {expected_counts}, got {table_counts}" -def load_file(path: str, file: str) -> Tuple[str, List[Dict[str, Any]]]: +def load_file(fs_client, path: str, file: str) -> Tuple[str, List[Dict[str, Any]]]: """ util function to load a filesystem destination file and return parsed content values may not be cast to the right type, especially for insert_values, please @@ -96,26 +97,22 @@ def load_file(path: str, file: str) -> Tuple[str, List[Dict[str, Any]]]: # table name will be last element of path table_name = path.split("/")[-1] - - # skip loads table - if table_name == "_dlt_loads": - return table_name, [] - full_path = posixpath.join(path, file) # load jsonl if ext == "jsonl": - with open(full_path, "rU", encoding="utf-8") as f: - for line in f: + file_text = fs_client.read_text(full_path) + for line in file_text.split("\n"): + if line: result.append(json.loads(line)) # load insert_values (this is a bit volatile if the exact format of the source file changes) elif ext == "insert_values": - with open(full_path, "rU", encoding="utf-8") as f: - lines = f.readlines() - # extract col names - cols = lines[0][15:-2].split(",") - for line in lines[2:]: + file_text = fs_client.read_text(full_path) + lines = file_text.split("\n") + cols = lines[0][15:-2].split(",") + for line in lines[2:]: + if line: values = line[1:-3].split(",") result.append(dict(zip(cols, values))) @@ -123,20 +120,20 @@ def load_file(path: str, file: str) -> Tuple[str, List[Dict[str, Any]]]: elif ext == "parquet": import pyarrow.parquet as pq - with open(full_path, "rb") as f: - table = pq.read_table(f) - cols = table.column_names - count = 0 - for column in table: - column_name = cols[count] - item_count = 0 - for item in column.to_pylist(): - if len(result) <= item_count: - result.append({column_name: item}) - else: - result[item_count][column_name] = item - item_count += 1 - count += 1 + file_bytes = fs_client.read_bytes(full_path) + table = pq.read_table(io.BytesIO(file_bytes)) + cols = table.column_names + count = 0 + for column in table: + column_name = cols[count] + item_count = 0 + for item in column.to_pylist(): + if len(result) <= item_count: + result.append({column_name: item}) + else: + result[item_count][column_name] = item + item_count += 1 + count += 1 return table_name, result @@ -149,7 +146,7 @@ def load_files(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[Dict[str, A client.dataset_path, detail=False, refresh=True ): for file in files: - table_name, items = load_file(basedir, file) + table_name, items = load_file(client.fs_client, basedir, file) if table_name not in table_names: continue if table_name in result: @@ -157,10 +154,6 @@ def load_files(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[Dict[str, A else: result[table_name] = items - # loads file is special case - if LOADS_TABLE_NAME in table_names and file.find(".{LOADS_TABLE_NAME}."): - result[LOADS_TABLE_NAME] = [] - return result From 77e24996e05f8bfe0b19079bfd59418add3cb556 Mon Sep 17 00:00:00 2001 From: Jorrit Sandbrink <47451109+jorritsandbrink@users.noreply.github.com> Date: Wed, 17 Apr 2024 21:55:34 +0400 Subject: [PATCH 18/41] Remove `staging-optimized` replace strategy for `synapse` (#1231) * remove staging-optimized replace strategy for synapse * fix athena iceberg locations --------- Co-authored-by: Jorrit Sandbrink Co-authored-by: Dave --- dlt/destinations/impl/synapse/synapse.py | 45 +------------------ .../dlt-ecosystem/destinations/synapse.md | 3 +- .../synapse/test_synapse_table_indexing.py | 9 +--- 3 files changed, 3 insertions(+), 54 deletions(-) diff --git a/dlt/destinations/impl/synapse/synapse.py b/dlt/destinations/impl/synapse/synapse.py index 1c58bc4dbb..f52b64b9d9 100644 --- a/dlt/destinations/impl/synapse/synapse.py +++ b/dlt/destinations/impl/synapse/synapse.py @@ -127,21 +127,7 @@ def _get_columstore_valid_column(self, c: TColumnSchema) -> TColumnSchema: def _create_replace_followup_jobs( self, table_chain: Sequence[TTableSchema] ) -> List[NewLoadJob]: - if self.config.replace_strategy == "staging-optimized": - # we must recreate staging table after SCHEMA TRANSFER - job_params: SqlJobParams = {"table_chain_create_table_statements": {}} - create_statements = job_params["table_chain_create_table_statements"] - with self.with_staging_dataset(): - for table in table_chain: - columns = [c for c in self.schema.get_table_columns(table["name"]).values()] - # generate CREATE TABLE statement - create_statements[table["name"]] = self._get_table_update_sql( - table["name"], columns, generate_alter=False - ) - return [ - SynapseStagingCopyJob.from_table_chain(table_chain, self.sql_client, job_params) - ] - return super()._create_replace_followup_jobs(table_chain) + return SqlJobClientBase._create_replace_followup_jobs(self, table_chain) def prepare_load_table(self, table_name: str, staging: bool = False) -> TTableSchema: table = super().prepare_load_table(table_name, staging) @@ -149,9 +135,6 @@ def prepare_load_table(self, table_name: str, staging: bool = False) -> TTableSc # Staging tables should always be heap tables, because "when you are # temporarily landing data in dedicated SQL pool, you may find that # using a heap table makes the overall process faster." - # "staging-optimized" is not included, because in that strategy the - # staging table becomes the final table, so we should already create - # it with the desired index type. table[TABLE_INDEX_TYPE_HINT] = "heap" # type: ignore[typeddict-unknown-key] elif table_name in self.schema.dlt_table_names(): # dlt tables should always be heap tables, because "for small lookup @@ -186,32 +169,6 @@ def start_file_load(self, table: TTableSchema, file_path: str, load_id: str) -> return job -class SynapseStagingCopyJob(SqlStagingCopyJob): - @classmethod - def generate_sql( - cls, - table_chain: Sequence[TTableSchema], - sql_client: SqlClientBase[Any], - params: Optional[SqlJobParams] = None, - ) -> List[str]: - sql: List[str] = [] - for table in table_chain: - with sql_client.with_staging_dataset(staging=True): - staging_table_name = sql_client.make_qualified_table_name(table["name"]) - table_name = sql_client.make_qualified_table_name(table["name"]) - # drop destination table - sql.append(f"DROP TABLE {table_name};") - # moving staging table to destination schema - sql.append( - f"ALTER SCHEMA {sql_client.fully_qualified_dataset_name()} TRANSFER" - f" {staging_table_name};" - ) - # recreate staging table - sql.extend(params["table_chain_create_table_statements"][table["name"]]) - - return sql - - class SynapseCopyFileLoadJob(CopyRemoteFileLoadJob): def __init__( self, diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index 2369a5c566..fae4571e34 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -129,7 +129,7 @@ pipeline = dlt.pipeline( ## Write disposition All write dispositions are supported. -If you set the [`replace` strategy](../../general-usage/full-loading.md) to `staging-optimized`, the destination tables will be dropped and replaced by the staging tables with an `ALTER SCHEMA ... TRANSFER` command. Please note that this operation is **not** atomic—it involves multiple DDL commands and Synapse does not support DDL transactions. +> ❗ The `staging-optimized` [`replace` strategy](../../general-usage/full-loading.md) is **not** implemented for Synapse. ## Data loading Data is loaded via `INSERT` statements by default. @@ -166,7 +166,6 @@ Possible values: >* **CLUSTERED COLUMNSTORE INDEX tables do not support the `varchar(max)`, `nvarchar(max)`, and `varbinary(max)` data types.** If you don't specify the `precision` for columns that map to any of these types, `dlt` will use the maximum lengths `varchar(4000)`, `nvarchar(4000)`, and `varbinary(8000)`. >* **While Synapse creates CLUSTERED COLUMNSTORE INDEXES by default, `dlt` creates HEAP tables by default.** HEAP is a more robust choice because it supports all data types and doesn't require conversions. >* **When using the `insert-from-staging` [`replace` strategy](../../general-usage/full-loading.md), the staging tables are always created as HEAP tables**—any configuration of the table index types is ignored. The HEAP strategy makes sense for staging tables for reasons explained [here](https://learn.microsoft.com/en-us/azure/synapse-analytics/sql-data-warehouse/sql-data-warehouse-tables-index#heap-tables). ->* **When using the `staging-optimized` [`replace` strategy](../../general-usage/full-loading.md), the staging tables are already created with the configured table index type**, because the staging table becomes the final table. >* **`dlt` system tables are always created as HEAP tables, regardless of any configuration.** This is in line with Microsoft's recommendation that "for small lookup tables, less than 60 million rows, consider using HEAP or clustered index for faster query performance." >* Child tables, if any, inherit the table index type of their parent table. diff --git a/tests/load/synapse/test_synapse_table_indexing.py b/tests/load/synapse/test_synapse_table_indexing.py index 71f419cbca..c9ecba17a1 100644 --- a/tests/load/synapse/test_synapse_table_indexing.py +++ b/tests/load/synapse/test_synapse_table_indexing.py @@ -100,18 +100,11 @@ def items_with_table_index_type_specified() -> Iterator[Any]: @pytest.mark.parametrize( "table_index_type,column_schema", TABLE_INDEX_TYPE_COLUMN_SCHEMA_PARAM_GRID ) -@pytest.mark.parametrize( - # Also test staging replace strategies, to make sure the final table index - # type is not affected by staging table index type adjustments. - "replace_strategy", - ["insert-from-staging", "staging-optimized"], -) def test_resource_table_index_type_configuration( table_index_type: TTableIndexType, column_schema: Union[List[TColumnSchema], None], - replace_strategy: str, ) -> None: - os.environ["DESTINATION__REPLACE_STRATEGY"] = replace_strategy + os.environ["DESTINATION__REPLACE_STRATEGY"] = "insert-from-staging" @dlt.resource( name="items_with_table_index_type_specified", From 89b8da54a95798c18506bd17c7145ba9213363c5 Mon Sep 17 00:00:00 2001 From: David Scharf Date: Thu, 18 Apr 2024 17:15:13 +0200 Subject: [PATCH 19/41] fixes bug, where configs where not injected for async functions (#1241) --- dlt/common/reflection/utils.py | 8 ++++---- tests/common/reflection/test_reflect_spec.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/dlt/common/reflection/utils.py b/dlt/common/reflection/utils.py index 9bd3cb6775..cbf38a7327 100644 --- a/dlt/common/reflection/utils.py +++ b/dlt/common/reflection/utils.py @@ -1,12 +1,12 @@ import ast import inspect import astunparse -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from dlt.common.typing import AnyFun -def get_literal_defaults(node: ast.FunctionDef) -> Dict[str, str]: +def get_literal_defaults(node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> Dict[str, str]: """Extract defaults from function definition node literally, as pieces of source code""" defaults: List[ast.expr] = [] if node.args.defaults: @@ -30,12 +30,12 @@ def get_literal_defaults(node: ast.FunctionDef) -> Dict[str, str]: return literal_defaults -def get_func_def_node(f: AnyFun) -> ast.FunctionDef: +def get_func_def_node(f: AnyFun) -> Union[ast.FunctionDef, ast.AsyncFunctionDef]: """Finds the function definition node for function f by parsing the source code of the f's module""" source, lineno = inspect.findsource(inspect.unwrap(f)) for node in ast.walk(ast.parse("".join(source))): - if isinstance(node, ast.FunctionDef): + if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): f_lineno = node.lineno - 1 # get line number of first decorator if node.decorator_list: diff --git a/tests/common/reflection/test_reflect_spec.py b/tests/common/reflection/test_reflect_spec.py index 952d0fc596..b83815c24a 100644 --- a/tests/common/reflection/test_reflect_spec.py +++ b/tests/common/reflection/test_reflect_spec.py @@ -358,3 +358,23 @@ def _f_4(str_str=300, p_def: bool = True): assert SPEC.get_resolvable_fields()["str_str"] == int # default assert fields["str_str"] == 300 + + +def test_reflect_async_function() -> None: + async def _f_1_as(str_str: str = dlt.config.value, blah: bool = dlt.config.value): + import asyncio + + await asyncio.sleep(1) + + SPEC_AS, fields_as = spec_from_signature(_f_1_as, inspect.signature(_f_1_as), False) + + def _f_1(str_str: str = dlt.config.value, blah: bool = dlt.config.value): + pass + + SPEC, fields = spec_from_signature(_f_1, inspect.signature(_f_1), False) + + # discovered fields are the same for sync and async functions + assert fields + assert fields == fields_as + assert len(SPEC.get_resolvable_fields()) == len(fields) == 2 + assert SPEC.get_resolvable_fields() == SPEC_AS.get_resolvable_fields() From cc9685fbff7e3e5d66f779006455e9973c78eb8f Mon Sep 17 00:00:00 2001 From: rudolfix Date: Thu, 18 Apr 2024 17:19:21 +0200 Subject: [PATCH 20/41] adds options to write headers, change delimiter (#1239) --- dlt/common/data_writers/writers.py | 32 ++++++++++++++++--- .../docs/dlt-ecosystem/file-formats/csv.md | 20 ++++++++++++ .../load/pipeline/test_filesystem_pipeline.py | 29 +++++++++++++++++ 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/dlt/common/data_writers/writers.py b/dlt/common/data_writers/writers.py index 2b14d8cd72..850a27e8bc 100644 --- a/dlt/common/data_writers/writers.py +++ b/dlt/common/data_writers/writers.py @@ -381,15 +381,27 @@ def writer_spec(cls) -> FileWriterSpec: ) +@configspec +class CsvDataWriterConfiguration(BaseConfiguration): + delimiter: str = "," + include_header: bool = True + + __section__: ClassVar[str] = known_sections.DATA_WRITER + + class CsvWriter(DataWriter): + @with_config(spec=CsvDataWriterConfiguration) def __init__( self, f: IO[Any], caps: DestinationCapabilitiesContext = None, + *, delimiter: str = ",", + include_header: bool = True, bytes_encoding: str = "utf-8", ) -> None: super().__init__(f, caps) + self.include_header = include_header self.delimiter = delimiter self.writer: csv.DictWriter[str] = None self.bytes_encoding = bytes_encoding @@ -404,7 +416,8 @@ def write_header(self, columns_schema: TTableSchemaColumns) -> None: delimiter=self.delimiter, quoting=csv.QUOTE_NONNUMERIC, ) - self.writer.writeheader() + if self.include_header: + self.writer.writeheader() # find row items that are of the complex type (could be abstracted out for use in other writers?) self.complex_indices = [ i for i, field in columns_schema.items() if field["data_type"] == "complex" @@ -499,11 +512,19 @@ def writer_spec(cls) -> FileWriterSpec: class ArrowToCsvWriter(DataWriter): + @with_config(spec=CsvDataWriterConfiguration) def __init__( - self, f: IO[Any], caps: DestinationCapabilitiesContext = None, delimiter: bytes = b"," + self, + f: IO[Any], + caps: DestinationCapabilitiesContext = None, + *, + delimiter: str = ",", + include_header: bool = True, ) -> None: super().__init__(f, caps) self.delimiter = delimiter + self._delimiter_b = delimiter.encode("ascii") + self.include_header = include_header self.writer: Any = None def write_header(self, columns_schema: TTableSchemaColumns) -> None: @@ -521,7 +542,8 @@ def write_data(self, rows: Sequence[Any]) -> None: self._f, row.schema, write_options=pyarrow.csv.WriteOptions( - include_header=True, delimiter=self.delimiter + include_header=self.include_header, + delimiter=self._delimiter_b, ), ) self._first_schema = row.schema @@ -573,10 +595,10 @@ def write_data(self, rows: Sequence[Any]) -> None: self.items_count += row.num_rows def write_footer(self) -> None: - if self.writer is None: + if self.writer is None and self.include_header: # write empty file self._f.write( - self.delimiter.join( + self._delimiter_b.join( [ b'"' + col["name"].encode("utf-8") + b'"' for col in self._columns_schema.values() diff --git a/docs/website/docs/dlt-ecosystem/file-formats/csv.md b/docs/website/docs/dlt-ecosystem/file-formats/csv.md index 4eb94b5ff0..dcd9e251f5 100644 --- a/docs/website/docs/dlt-ecosystem/file-formats/csv.md +++ b/docs/website/docs/dlt-ecosystem/file-formats/csv.md @@ -32,6 +32,26 @@ info = pipeline.run(some_source(), loader_file_format="csv") * UNIX new lines are used * dates are represented as ISO 8601 +### Change settings +You can change basic **csv** settings, this may be handy when working with **filesystem** destination. Other destinations are tested +with standard settings: + +* delimiter: change the delimiting character (default: ',') +* include_header: include the header row (default: True) + +```toml +[normalize.data_writer] +delimiter="|" +include_header=false +``` + +Or using environment variables: + +```sh +NORMALIZE__DATA_WRITER__DELIMITER=| +NORMALIZE__DATA_WRITER__INCLUDE_HEADER=False +``` + ## Limitations **arrow writer** diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index 20f326b160..b02525f4a4 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -145,6 +145,35 @@ def test_pipeline_csv_filesystem_destination(item_type: TestDataItemFormat) -> N assert len(csv_rows) == 3 +@pytest.mark.parametrize("item_type", ALL_TEST_DATA_ITEM_FORMATS) +def test_csv_options(item_type: TestDataItemFormat) -> None: + os.environ["DATA_WRITER__DISABLE_COMPRESSION"] = "True" + os.environ["RESTORE_FROM_DESTINATION"] = "False" + # set delimiter and disable headers + os.environ["NORMALIZE__DATA_WRITER__DELIMITER"] = "|" + os.environ["NORMALIZE__DATA_WRITER__INCLUDE_HEADER"] = "False" + # store locally + os.environ["DESTINATION__FILESYSTEM__BUCKET_URL"] = "file://_storage" + pipeline = dlt.pipeline( + pipeline_name="parquet_test_" + uniq_id(), + destination="filesystem", + dataset_name="parquet_test_" + uniq_id(), + ) + + item, rows, _ = arrow_table_all_data_types(item_type, include_json=False, include_time=True) + info = pipeline.run(item, table_name="table", loader_file_format="csv") + info.raise_on_failed_jobs() + job = info.load_packages[0].jobs["completed_jobs"][0].file_path + assert job.endswith("csv") + with open(job, "r", encoding="utf-8", newline="") as f: + csv_rows = list(csv.reader(f, dialect=csv.unix_dialect, delimiter="|")) + # no header + assert len(csv_rows) == 3 + # object csv adds dlt columns + dlt_columns = 2 if item_type == "object" else 0 + assert len(rows[0]) + dlt_columns == len(csv_rows[0]) + + def test_pipeline_parquet_filesystem_destination() -> None: import pyarrow.parquet as pq # Module is evaluated by other tests From 4750f621496894d263e05a743d997a2e44b33b60 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:11:38 +0200 Subject: [PATCH 21/41] Revert tzdata update and update lock (#1238) * Revert tzdata update and update lock * Add guide for contributors about dependency updates * Adjust section title * Revert black update * Adjust section title * Revert lockfile * Update lock hash * Remove example --- CONTRIBUTING.md | 31 ++++++++++++++++++++++++++----- poetry.lock | 8 ++++---- pyproject.toml | 3 ++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 895ad08229..a8a8cc37ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,11 +6,12 @@ Thank you for considering contributing to **dlt**! We appreciate your help in ma 1. [Getting Started](#getting-started) 2. [Submitting Changes](#submitting-changes) -3. [Linting](#linting) -4. [Testing](#testing) -5. [Local Development](#local-development) -6. [Publishing (Maintainers Only)](#publishing-maintainers-only) -7. [Resources](#resources) +3. [Adding or updating core dependencies](#adding-or-updating-core-dependencies) +4. [Linting](#linting) +5. [Testing](#testing) +6. [Local Development](#local-development) +7. [Publishing (Maintainers Only)](#publishing-maintainers-only) +8. [Resources](#resources) ## Before You Begin @@ -62,6 +63,26 @@ only the `duckdb` and `postgres` are available to forks. In case you submit a new destination or make changes to a destination that require credentials (so Bigquery, Snowflake, buckets etc.) you **should contact us so we can add you as contributor**. Then you should make a PR directly to the `dlt` repo. +## Adding or updating core dependencies + +Our objective is to maintain stability and compatibility of dlt across all environments. +By following these guidelines, we can make sure that dlt stays secure, reliable and compatible. +Please consider the following points carefully when proposing updates to dependencies. + +### Updating guidelines + +1. **Critical security or system integrity updates only:** + Major or minor version updates to dependencies should only be considered if there are critical security vulnerabilities or issues that impact the system's integrity. In such cases, updating is necessary to protect the system and the data it processes. + +2. **Using the '>=' operator:** + When specifying dependencies, please make sure to use the `>=` operator while also maintaining version minima. This approach ensures our project remains compatible with older systems and setups, mitigating potential unsolvable dependency conflicts. + +For example, if our project currently uses a package `example-package==1.2.3`, and a security update is +released as `1.2.4`, instead of updating to `example-package==1.2.4`, we can set it to `example-package>=1.2.3,<2.0.0`. This permits the necessary security update and at the same time +prevents the automatic jump to a potentially incompatible major version update in the future. +The other important note on using possible version minimas is to prevent potential cases where package +versions will not be resolvable. + ## Linting `dlt` uses `mypy` and `flake8` with several plugins for linting. diff --git a/poetry.lock b/poetry.lock index 257714ad6a..bb3001d25f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8635,13 +8635,13 @@ files = [ [[package]] name = "tzdata" -version = "2024.1" +version = "2023.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, - {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, ] [[package]] @@ -9090,4 +9090,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<3.13" -content-hash = "0bd3559c3b2e0ad8a33bfdb81586f1db8399d862728e8899b259961c8e175abf" +content-hash = "dfd9c83255cedff494fa28475473f232cae56ec49451f770c6940f0cb3e2b33e" diff --git a/pyproject.toml b/pyproject.toml index 301ac726ea..97b1cb5aba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ simplejson = ">=3.17.5" PyYAML = ">=5.4.1" semver = ">=2.13.0" hexbytes = ">=0.2.2" -tzdata = ">=2024.1" +tzdata = ">=2022.1" tomlkit = ">=0.11.3" pathvalidate = ">=2.5.2" typing-extensions = ">=4.0.0" @@ -80,6 +80,7 @@ pyodbc = {version = "^4.0.39", optional = true} qdrant-client = {version = "^1.6.4", optional = true, extras = ["fastembed"]} databricks-sql-connector = {version = ">=2.9.3,<3.0.0", optional = true} dbt-databricks = {version = "^1.7.3", optional = true} +black = "23.9.1" [tool.poetry.extras] dbt = ["dbt-core", "dbt-redshift", "dbt-bigquery", "dbt-duckdb", "dbt-snowflake", "dbt-athena-community", "dbt-databricks"] From 902963c7462c4568f67067f7dd73f513bcaad1d7 Mon Sep 17 00:00:00 2001 From: Marcin Rudolf Date: Fri, 19 Apr 2024 10:26:44 +0200 Subject: [PATCH 22/41] bumps to pre-release 0.4.9a2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 97b1cb5aba..1e25c6cd71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dlt" -version = "0.4.9a0" +version = "0.4.9a2" description = "dlt is an open-source python-first scalable data loading library that does not require any backend to run." authors = ["dltHub Inc. "] maintainers = [ "Marcin Rudolf ", "Adrian Brudaru ", "Ty Dunn "] From c43ac6f9cc866c7798597906f9d62a8d8a0b183d Mon Sep 17 00:00:00 2001 From: Jorrit Sandbrink <47451109+jorritsandbrink@users.noreply.github.com> Date: Fri, 19 Apr 2024 17:42:12 +0400 Subject: [PATCH 23/41] Fix SQL incompatibility issues for `scd2` on `bigquery` and `databricks` (#1247) * add bigquery datetime literal formatting * refactor not exists to not in for bigquery and databricks compatibility * mark main scd2 test as essential --------- Co-authored-by: Jorrit Sandbrink --- dlt/common/data_writers/escape.py | 10 +++++++- dlt/common/destination/capabilities.py | 5 ++++ dlt/destinations/impl/bigquery/__init__.py | 6 ++++- dlt/destinations/sql_jobs.py | 27 +++++++++++----------- tests/load/pipeline/test_scd2.py | 1 + 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/dlt/common/data_writers/escape.py b/dlt/common/data_writers/escape.py index e812afdaf1..e047778e3d 100644 --- a/dlt/common/data_writers/escape.py +++ b/dlt/common/data_writers/escape.py @@ -167,4 +167,12 @@ def format_datetime_literal(v: pendulum.DateTime, precision: int = 6, no_tz: boo timespec = "milliseconds" elif precision < 3: timespec = "seconds" - return v.isoformat(sep=" ", timespec=timespec) + return "'" + v.isoformat(sep=" ", timespec=timespec) + "'" + + +def format_bigquery_datetime_literal( + v: pendulum.DateTime, precision: int = 6, no_tz: bool = False +) -> str: + """Returns BigQuery-adjusted datetime literal by prefixing required `TIMESTAMP` indicator.""" + # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#timestamp_literals + return "TIMESTAMP " + format_datetime_literal(v, precision, no_tz) diff --git a/dlt/common/destination/capabilities.py b/dlt/common/destination/capabilities.py index 286a295e93..8432f8b544 100644 --- a/dlt/common/destination/capabilities.py +++ b/dlt/common/destination/capabilities.py @@ -9,6 +9,7 @@ DestinationLoadingWithoutStagingNotSupported, ) from dlt.common.utils import identity +from dlt.common.pendulum import pendulum from dlt.common.arithmetics import DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE from dlt.common.wei import EVM_DECIMAL_PRECISION @@ -32,6 +33,7 @@ class DestinationCapabilitiesContext(ContainerInjectableContext): supported_staging_file_formats: Sequence[TLoaderFileFormat] = None escape_identifier: Callable[[str], str] = None escape_literal: Callable[[Any], Any] = None + format_datetime_literal: Callable[..., str] = None decimal_precision: Tuple[int, int] = None wei_precision: Tuple[int, int] = None max_identifier_length: int = None @@ -61,6 +63,8 @@ class DestinationCapabilitiesContext(ContainerInjectableContext): def generic_capabilities( preferred_loader_file_format: TLoaderFileFormat = None, ) -> "DestinationCapabilitiesContext": + from dlt.common.data_writers.escape import format_datetime_literal + caps = DestinationCapabilitiesContext() caps.preferred_loader_file_format = preferred_loader_file_format caps.supported_loader_file_formats = ["jsonl", "insert_values", "parquet", "csv"] @@ -68,6 +72,7 @@ def generic_capabilities( caps.supported_staging_file_formats = [] caps.escape_identifier = identity caps.escape_literal = serialize_value + caps.format_datetime_literal = format_datetime_literal caps.decimal_precision = (DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE) caps.wei_precision = (EVM_DECIMAL_PRECISION, 0) caps.max_identifier_length = 65536 diff --git a/dlt/destinations/impl/bigquery/__init__.py b/dlt/destinations/impl/bigquery/__init__.py index 6d1491817a..d33466ed5e 100644 --- a/dlt/destinations/impl/bigquery/__init__.py +++ b/dlt/destinations/impl/bigquery/__init__.py @@ -1,4 +1,7 @@ -from dlt.common.data_writers.escape import escape_bigquery_identifier +from dlt.common.data_writers.escape import ( + escape_bigquery_identifier, + format_bigquery_datetime_literal, +) from dlt.common.destination import DestinationCapabilitiesContext from dlt.common.arithmetics import DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE @@ -11,6 +14,7 @@ def capabilities() -> DestinationCapabilitiesContext: caps.supported_staging_file_formats = ["parquet", "jsonl"] caps.escape_identifier = escape_bigquery_identifier caps.escape_literal = None + caps.format_datetime_literal = format_bigquery_datetime_literal caps.decimal_precision = (DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE) caps.wei_precision = (76, 38) caps.max_identifier_length = 1024 diff --git a/dlt/destinations/sql_jobs.py b/dlt/destinations/sql_jobs.py index eadedb742e..86eaa9236a 100644 --- a/dlt/destinations/sql_jobs.py +++ b/dlt/destinations/sql_jobs.py @@ -1,7 +1,6 @@ from typing import Any, Dict, List, Sequence, Tuple, cast, TypedDict, Optional import yaml -from dlt.common.data_writers.escape import format_datetime_literal from dlt.common.logger import pretty_format_exception from dlt.common.pendulum import pendulum @@ -521,28 +520,30 @@ def gen_scd2_sql( staging_root_table_name = sql_client.make_qualified_table_name(root_table["name"]) # get column names - escape_id = sql_client.capabilities.escape_identifier + caps = sql_client.capabilities + escape_id = caps.escape_identifier from_, to = list(map(escape_id, get_validity_column_names(root_table))) # validity columns hash_ = escape_id( get_first_column_name_with_prop(root_table, "x-row-version") ) # row hash column # define values for validity columns + format_datetime_literal = caps.format_datetime_literal + if format_datetime_literal is None: + format_datetime_literal = ( + DestinationCapabilitiesContext.generic_capabilities().format_datetime_literal + ) boundary_ts = format_datetime_literal( current_load_package()["state"]["created_at"], - sql_client.capabilities.timestamp_precision, - ) - active_record_ts = format_datetime_literal( - HIGH_TS, sql_client.capabilities.timestamp_precision + caps.timestamp_precision, ) + active_record_ts = format_datetime_literal(HIGH_TS, caps.timestamp_precision) # retire updated and deleted records sql.append(f""" - UPDATE {root_table_name} SET {to} = '{boundary_ts}' - WHERE NOT EXISTS ( - SELECT s.{hash_} FROM {staging_root_table_name} AS s - WHERE {root_table_name}.{hash_} = s.{hash_} - ) AND {to} = '{active_record_ts}'; + UPDATE {root_table_name} SET {to} = {boundary_ts} + WHERE {to} = {active_record_ts} + AND {hash_} NOT IN (SELECT {hash_} FROM {staging_root_table_name}); """) # insert new active records in root table @@ -550,9 +551,9 @@ def gen_scd2_sql( col_str = ", ".join([c for c in columns if c not in (from_, to)]) sql.append(f""" INSERT INTO {root_table_name} ({col_str}, {from_}, {to}) - SELECT {col_str}, '{boundary_ts}' AS {from_}, '{active_record_ts}' AS {to} + SELECT {col_str}, {boundary_ts} AS {from_}, {active_record_ts} AS {to} FROM {staging_root_table_name} AS s - WHERE NOT EXISTS (SELECT s.{hash_} FROM {root_table_name} AS f WHERE f.{hash_} = s.{hash_}); + WHERE {hash_} NOT IN (SELECT {hash_} FROM {root_table_name}); """) # insert list elements for new active records in child tables diff --git a/tests/load/pipeline/test_scd2.py b/tests/load/pipeline/test_scd2.py index cf313eaa61..65a0742195 100644 --- a/tests/load/pipeline/test_scd2.py +++ b/tests/load/pipeline/test_scd2.py @@ -81,6 +81,7 @@ def assert_records_as_set(actual: List[Dict[str, Any]], expected: List[Dict[str, assert actual_set == expected_set +@pytest.mark.essential @pytest.mark.parametrize( "destination_config,simple,validity_column_names", [ # test basic case for alle SQL destinations supporting merge From 10d9e2073f3db4dc28c21c8314a225d98bb8435f Mon Sep 17 00:00:00 2001 From: David Scharf Date: Mon, 22 Apr 2024 14:32:18 +0200 Subject: [PATCH 24/41] enable all tests for bigquery always (#1245) --- .github/workflows/test_destination_bigquery.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/test_destination_bigquery.yml b/.github/workflows/test_destination_bigquery.yml index cc55d5a5b2..91ed395fb1 100644 --- a/.github/workflows/test_destination_bigquery.yml +++ b/.github/workflows/test_destination_bigquery.yml @@ -70,12 +70,6 @@ jobs: - name: create secrets.toml run: pwd && echo "$DLT_SECRETS_TOML" > tests/.dlt/secrets.toml - - run: | - poetry run pytest tests/load -m "essential" - name: Run essential tests Linux - if: ${{ ! (contains(github.event.pull_request.labels.*.name, 'ci full') || github.event_name == 'schedule')}} - - run: | poetry run pytest tests/load name: Run all tests Linux - if: ${{ contains(github.event.pull_request.labels.*.name, 'ci full') || github.event_name == 'schedule'}} From f6295f93ce99dd29493ed5c870ba4975535b425d Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Mon, 22 Apr 2024 21:27:25 +0200 Subject: [PATCH 25/41] Check for default schema and schema name in streamlit session (#1155) * Check for default schema and schema name in streamlit session * Do not show resource state if it is not available * Fix mypy errors * Remove the message if there is no schema in state * Simplify code --- dlt/helpers/streamlit_app/blocks/load_info.py | 53 ++++++++++--------- .../streamlit_app/blocks/resource_state.py | 9 +--- dlt/helpers/streamlit_app/pages/dashboard.py | 14 +++-- dlt/helpers/streamlit_app/widgets/schema.py | 2 +- .../test_streamlit_show_resources.py | 2 +- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/dlt/helpers/streamlit_app/blocks/load_info.py b/dlt/helpers/streamlit_app/blocks/load_info.py index 9482cb5afa..bfd9d386df 100644 --- a/dlt/helpers/streamlit_app/blocks/load_info.py +++ b/dlt/helpers/streamlit_app/blocks/load_info.py @@ -8,33 +8,34 @@ def last_load_info(pipeline: dlt.Pipeline) -> None: - loads_df = query_data_live( - pipeline, - f"SELECT load_id, inserted_at FROM {pipeline.default_schema.loads_table_name} WHERE" - " status = 0 ORDER BY inserted_at DESC LIMIT 101 ", - ) - - if loads_df is None: - st.error( - "Load info is not available", - icon="🚨", + if pipeline.default_schema_name: + loads_df = query_data_live( + pipeline, + f"SELECT load_id, inserted_at FROM {pipeline.default_schema.loads_table_name} WHERE" + " status = 0 ORDER BY inserted_at DESC LIMIT 101 ", ) - else: - loads_no = loads_df.shape[0] - if loads_df.shape[0] > 0: - rel_time = ( - humanize.naturaldelta( - pendulum.now() - pendulum.from_timestamp(loads_df.iloc[0, 1].timestamp()) - ) - + " ago" + + if loads_df is None: + st.error( + "Load info is not available", + icon="🚨", ) - last_load_id = loads_df.iloc[0, 0] - if loads_no > 100: - loads_no = "> " + str(loads_no) else: - rel_time = "---" - last_load_id = "---" + loads_no = loads_df.shape[0] + if loads_df.shape[0] > 0: + rel_time = ( + humanize.naturaldelta( + pendulum.now() - pendulum.from_timestamp(loads_df.iloc[0, 1].timestamp()) + ) + + " ago" + ) + last_load_id = loads_df.iloc[0, 0] + if loads_no > 100: + loads_no = "> " + str(loads_no) + else: + rel_time = "---" + last_load_id = "---" - stat("Last load time", rel_time, border_left_width=4) - stat("Last load id", last_load_id) - stat("Total number of loads", loads_no) + stat("Last load time", rel_time, border_left_width=4) + stat("Last load id", last_load_id) + stat("Total number of loads", loads_no) diff --git a/dlt/helpers/streamlit_app/blocks/resource_state.py b/dlt/helpers/streamlit_app/blocks/resource_state.py index dabbea4d46..69fae92e81 100644 --- a/dlt/helpers/streamlit_app/blocks/resource_state.py +++ b/dlt/helpers/streamlit_app/blocks/resource_state.py @@ -22,13 +22,8 @@ def resource_state_info( resource_name: str, ) -> None: sources_state = pipeline.state.get("sources") or {} - schema = sources_state.get(schema_name) - if not schema: - st.error(f"Schema with name: {schema_name} is not found") - return - - resource = schema["resources"].get(resource_name) - + schema = sources_state.get(schema_name, {}) + resource = schema.get("resources", {}).get(resource_name) with st.expander("Resource state", expanded=(resource is None)): if not resource: st.info(f"{resource_name} is missing resource state") diff --git a/dlt/helpers/streamlit_app/pages/dashboard.py b/dlt/helpers/streamlit_app/pages/dashboard.py index 656dd6ecdf..420c9b2021 100644 --- a/dlt/helpers/streamlit_app/pages/dashboard.py +++ b/dlt/helpers/streamlit_app/pages/dashboard.py @@ -29,12 +29,16 @@ def write_data_explorer_page( st.subheader("Schemas and tables", divider="rainbow") schema_picker(pipeline) - tables = sorted( - st.session_state["schema"].data_tables(), - key=lambda table: table["name"], - ) + if schema := st.session_state["schema"]: + tables = sorted( + schema.data_tables(), + key=lambda table: table["name"], + ) + + list_table_hints(pipeline, tables) + else: + st.warning("No schemas found") - list_table_hints(pipeline, tables) maybe_run_query( pipeline, show_charts=show_charts, diff --git a/dlt/helpers/streamlit_app/widgets/schema.py b/dlt/helpers/streamlit_app/widgets/schema.py index f7883bc45e..c9dd87097e 100644 --- a/dlt/helpers/streamlit_app/widgets/schema.py +++ b/dlt/helpers/streamlit_app/widgets/schema.py @@ -16,6 +16,6 @@ def schema_picker(pipeline: dlt.Pipeline) -> None: ) schema = pipeline.schemas.get(selected_schema_name) + st.session_state["schema"] = schema if schema: st.subheader(f"Schema: {schema.name}") - st.session_state["schema"] = schema diff --git a/tests/helpers/streamlit_tests/test_streamlit_show_resources.py b/tests/helpers/streamlit_tests/test_streamlit_show_resources.py index dd807260fe..691af8a9d1 100644 --- a/tests/helpers/streamlit_tests/test_streamlit_show_resources.py +++ b/tests/helpers/streamlit_tests/test_streamlit_show_resources.py @@ -13,7 +13,7 @@ import dlt -from streamlit.testing.v1 import AppTest # type: ignore +from streamlit.testing.v1 import AppTest # type: ignore[import-not-found] from dlt.helpers.streamlit_app.utils import render_with_pipeline from dlt.pipeline.exceptions import CannotRestorePipelineException From 88ac111b4ebaac78a3c55309baf6248ef6eaa7ef Mon Sep 17 00:00:00 2001 From: rudolfix Date: Tue, 23 Apr 2024 13:05:17 +0200 Subject: [PATCH 26/41] adds quoting style option to csv writer config (#1262) --- dlt/common/data_writers/writers.py | 24 ++++++++++++- dlt/destinations/impl/postgres/postgres.py | 2 ++ .../docs/dlt-ecosystem/file-formats/csv.md | 7 ++++ .../load/pipeline/test_filesystem_pipeline.py | 35 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/dlt/common/data_writers/writers.py b/dlt/common/data_writers/writers.py index 850a27e8bc..8936dae605 100644 --- a/dlt/common/data_writers/writers.py +++ b/dlt/common/data_writers/writers.py @@ -381,10 +381,14 @@ def writer_spec(cls) -> FileWriterSpec: ) +CsvQuoting = Literal["quote_all", "quote_needed"] + + @configspec class CsvDataWriterConfiguration(BaseConfiguration): delimiter: str = "," include_header: bool = True + quoting: CsvQuoting = "quote_needed" __section__: ClassVar[str] = known_sections.DATA_WRITER @@ -398,23 +402,32 @@ def __init__( *, delimiter: str = ",", include_header: bool = True, + quoting: CsvQuoting = "quote_needed", bytes_encoding: str = "utf-8", ) -> None: super().__init__(f, caps) self.include_header = include_header self.delimiter = delimiter + self.quoting: CsvQuoting = quoting self.writer: csv.DictWriter[str] = None self.bytes_encoding = bytes_encoding def write_header(self, columns_schema: TTableSchemaColumns) -> None: self._columns_schema = columns_schema + if self.quoting == "quote_needed": + quoting: Literal[1, 2] = csv.QUOTE_NONNUMERIC + elif self.quoting == "quote_all": + quoting = csv.QUOTE_ALL + else: + raise ValueError(self.quoting) + self.writer = csv.DictWriter( self._f, fieldnames=list(columns_schema.keys()), extrasaction="ignore", dialect=csv.unix_dialect, delimiter=self.delimiter, - quoting=csv.QUOTE_NONNUMERIC, + quoting=quoting, ) if self.include_header: self.writer.writeheader() @@ -520,11 +533,13 @@ def __init__( *, delimiter: str = ",", include_header: bool = True, + quoting: CsvQuoting = "quote_needed", ) -> None: super().__init__(f, caps) self.delimiter = delimiter self._delimiter_b = delimiter.encode("ascii") self.include_header = include_header + self.quoting: CsvQuoting = quoting self.writer: Any = None def write_header(self, columns_schema: TTableSchemaColumns) -> None: @@ -537,6 +552,12 @@ def write_data(self, rows: Sequence[Any]) -> None: for row in rows: if isinstance(row, (pyarrow.Table, pyarrow.RecordBatch)): if not self.writer: + if self.quoting == "quote_needed": + quoting = "needed" + elif self.quoting == "quote_all": + quoting = "all_valid" + else: + raise ValueError(self.quoting) try: self.writer = pyarrow.csv.CSVWriter( self._f, @@ -544,6 +565,7 @@ def write_data(self, rows: Sequence[Any]) -> None: write_options=pyarrow.csv.WriteOptions( include_header=self.include_header, delimiter=self._delimiter_b, + quoting_style=quoting, ), ) self._first_schema = row.schema diff --git a/dlt/destinations/impl/postgres/postgres.py b/dlt/destinations/impl/postgres/postgres.py index 0922ed025e..11cee208b1 100644 --- a/dlt/destinations/impl/postgres/postgres.py +++ b/dlt/destinations/impl/postgres/postgres.py @@ -112,6 +112,8 @@ def __init__(self, table_name: str, file_path: str, sql_client: Psycopg2SqlClien with FileStorage.open_zipsafe_ro(file_path, "rb") as f: # all headers in first line headers = f.readline().decode("utf-8").strip() + # quote headers if not quoted - all special keywords like "binary" must be quoted + headers = ",".join(h if h.startswith('"') else f'"{h}"' for h in headers.split(",")) qualified_table_name = sql_client.make_qualified_table_name(table_name) copy_sql = ( "COPY %s (%s) FROM STDIN WITH (FORMAT CSV, DELIMITER ',', NULL '', FORCE_NULL(%s))" diff --git a/docs/website/docs/dlt-ecosystem/file-formats/csv.md b/docs/website/docs/dlt-ecosystem/file-formats/csv.md index dcd9e251f5..436607f514 100644 --- a/docs/website/docs/dlt-ecosystem/file-formats/csv.md +++ b/docs/website/docs/dlt-ecosystem/file-formats/csv.md @@ -31,6 +31,7 @@ info = pipeline.run(some_source(), loader_file_format="csv") * `NULL` values are empty strings * UNIX new lines are used * dates are represented as ISO 8601 +* quoting style is "when needed" ### Change settings You can change basic **csv** settings, this may be handy when working with **filesystem** destination. Other destinations are tested @@ -38,11 +39,16 @@ with standard settings: * delimiter: change the delimiting character (default: ',') * include_header: include the header row (default: True) +* quoting: **quote_all** - all values are quoted, **quote_needed** - quote only values that need quoting (default: `quote_needed`) + +When **quote_needed** is selected: in case of Python csv writer all non-numeric values are quoted. In case of pyarrow csv writer, the exact behavior is not described in the documentation. We observed that in some cases, strings are not quoted as well. + ```toml [normalize.data_writer] delimiter="|" include_header=false +quoting="quote_all" ``` Or using environment variables: @@ -50,6 +56,7 @@ Or using environment variables: ```sh NORMALIZE__DATA_WRITER__DELIMITER=| NORMALIZE__DATA_WRITER__INCLUDE_HEADER=False +NORMALIZE__DATA_WRITER__QUOTING=quote_all ``` ## Limitations diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index b02525f4a4..339c4390bf 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -174,6 +174,41 @@ def test_csv_options(item_type: TestDataItemFormat) -> None: assert len(rows[0]) + dlt_columns == len(csv_rows[0]) +@pytest.mark.parametrize("item_type", ALL_TEST_DATA_ITEM_FORMATS) +def test_csv_quoting_style(item_type: TestDataItemFormat) -> None: + os.environ["DATA_WRITER__DISABLE_COMPRESSION"] = "True" + os.environ["RESTORE_FROM_DESTINATION"] = "False" + # set quotes to all + os.environ["NORMALIZE__DATA_WRITER__QUOTING"] = "quote_all" + os.environ["NORMALIZE__DATA_WRITER__INCLUDE_HEADER"] = "False" + # store locally + os.environ["DESTINATION__FILESYSTEM__BUCKET_URL"] = "file://_storage" + pipeline = dlt.pipeline( + pipeline_name="parquet_test_" + uniq_id(), + destination="filesystem", + dataset_name="parquet_test_" + uniq_id(), + ) + + item, _, _ = arrow_table_all_data_types(item_type, include_json=False, include_time=True) + info = pipeline.run(item, table_name="table", loader_file_format="csv") + info.raise_on_failed_jobs() + job = info.load_packages[0].jobs["completed_jobs"][0].file_path + assert job.endswith("csv") + with open(job, "r", encoding="utf-8", newline="") as f: + # we skip headers and every line of data has 3 physical lines (due to string value in arrow_table_all_data_types) + for line in f: + line += f.readline() + line += f.readline() + # all elements are quoted + for elem in line.strip().split(","): + # NULL values are not quoted on arrow writer + assert ( + elem.startswith('"') + and elem.endswith('"') + or (len(elem) == 0 and item_type != "object") + ) + + def test_pipeline_parquet_filesystem_destination() -> None: import pyarrow.parquet as pq # Module is evaluated by other tests From 39a1bd8416d3c3cb4a00d207cd8b45b6dc5a1e6c Mon Sep 17 00:00:00 2001 From: David Scharf Date: Tue, 23 Apr 2024 13:16:00 +0200 Subject: [PATCH 27/41] fix dbt tests (#1256) * fix test_dbt_commands profile * update dbt core tests --- tests/helpers/dbt_tests/cases/profiles.yml | 2 +- tests/helpers/dbt_tests/test_runner_dbt_versions.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/helpers/dbt_tests/cases/profiles.yml b/tests/helpers/dbt_tests/cases/profiles.yml index e009669be7..75edf7b694 100644 --- a/tests/helpers/dbt_tests/cases/profiles.yml +++ b/tests/helpers/dbt_tests/cases/profiles.yml @@ -11,5 +11,5 @@ jaffle_shop: user: "{{ env_var('DLT__CREDENTIALS__USERNAME') }}" password: "{{ env_var('DLT__CREDENTIALS__PASSWORD') }}" port: 5432 - dbname: dlt_data + dbname: "{{ env_var('DLT__CREDENTIALS__DATABASE') }}" schema: "{{ var('dbt_schema') }}" \ No newline at end of file diff --git a/tests/helpers/dbt_tests/test_runner_dbt_versions.py b/tests/helpers/dbt_tests/test_runner_dbt_versions.py index a7408f00f3..5b9b07fcc5 100644 --- a/tests/helpers/dbt_tests/test_runner_dbt_versions.py +++ b/tests/helpers/dbt_tests/test_runner_dbt_versions.py @@ -43,14 +43,16 @@ def client() -> Iterator[PostgresClient]: PACKAGE_PARAMS = [ - ("postgres", "1.1.3"), - ("postgres", "1.2.4"), - ("postgres", "1.3.2"), - ("postgres", "1.4.0"), + # ("postgres", "1.1.3"), + # ("postgres", "1.2.4"), + # ("postgres", "1.3.2"), + # ("postgres", "1.4.0"), ("postgres", "1.5.2"), + ("postgres", "1.6.13"), ("postgres", None), - ("snowflake", "1.4.0"), + # ("snowflake", "1.4.0"), ("snowflake", "1.5.2"), + ("snowflake", "1.6.13"), ("snowflake", None), ] PACKAGE_IDS = [ From a799ec1ac1d571cd7dd3ac34ebcfaf326865b0d2 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:13:19 +0200 Subject: [PATCH 28/41] Add seconds and millisecond timestamps to filesystem date placeholders (#1260) * Add seconds to filesystem date placeholders * Update docs * Fix formatting * Add milliseconds timestamps and placeholders --- dlt/destinations/path_utils.py | 13 +++++++ .../dlt-ecosystem/destinations/filesystem.md | 14 ++++++-- tests/destinations/test_path_utils.py | 36 +++++++++++++++++++ .../load/pipeline/test_filesystem_pipeline.py | 6 +++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/dlt/destinations/path_utils.py b/dlt/destinations/path_utils.py index bd870ba995..4777bdfa2b 100644 --- a/dlt/destinations/path_utils.py +++ b/dlt/destinations/path_utils.py @@ -39,6 +39,14 @@ "ddd", # Mon, Tue, Wed "dd", # Mo, Tu, We "d", # 0-6 + # Seconds + "ss", # 01-59 + "s", # 0-59 + # Fractional seconds + "SSSS", # 000[0..] 001[0..] ... 998[0..] 999[0..] + "SSS", # 000 001 ... 998 999 + "SS", # 00, 01, 02 ... 98, 99 + "S", # 0 1 ... 8 9 # Quarters of the year "Q", # 1, 2, 3, 4 } @@ -52,7 +60,10 @@ "ext", "curr_date", "timestamp", + "timestamp", + "timestamp_ms", "load_package_timestamp", + "load_package_timestamp_ms", } ) @@ -81,6 +92,7 @@ def prepare_datetime_params( if load_package_timestamp: current_timestamp = ensure_pendulum_datetime(load_package_timestamp) params["load_package_timestamp"] = str(int(current_timestamp.timestamp())) + params["load_package_timestamp_ms"] = current_timestamp.format("SSS") if not current_datetime: if current_timestamp: @@ -91,6 +103,7 @@ def prepare_datetime_params( current_datetime = pendulum.now() params["timestamp"] = str(int(current_datetime.timestamp())) + params["timestamp_ms"] = current_datetime.format("SSS") params["curr_date"] = str(current_datetime.date()) for format_string in DATETIME_PLACEHOLDERS: diff --git a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md index 76e0108461..b577561e4a 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/filesystem.md +++ b/docs/website/docs/dlt-ecosystem/destinations/filesystem.md @@ -232,8 +232,10 @@ The default layout format has changed from `{schema_name}.{table_name}.{load_id} Keep in mind all values are lowercased. ::: -* `timestamp` - the current timestamp in Unix Timestamp format rounded to minutes -* `load_package_timestamp` - timestamp from [load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) in Unix Timestamp format rounded to minutes +* `timestamp` - the current timestamp in Unix Timestamp format rounded to seconds +* `timestamp_ms` - the current timestamp in Unix Timestamp format rounded to milliseconds +* `load_package_timestamp` - timestamp from [load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) in Unix Timestamp format rounded to seconds +* `load_package_timestamp_ms` - timestamp from [load package](../../general-usage/destination-tables.md#load-packages-and-load-ids) in Unix Timestamp format rounded to milliseconds * Years * `YYYY` - 2024, 2025 * `Y` - 2024, 2025 @@ -251,6 +253,14 @@ Keep in mind all values are lowercased. * Minutes * `mm` - 00, 01, 02...59 * `m` - 0, 1, 2...59 +* Seconds + * `ss` - 00, 01, 02...59 + * `s` - 0, 1, 2...59 +* Fractional seconds + * `SSSS` - 000[0..] 001[0..] ... 998[0..] 999[0..] + * `SSS` - 000 001 ... 998 999 + * `SS` - 00, 01, 02 ... 98, 99 + * `S` - 0 1 ... 8 9 * Days of the week * `dddd` - Monday, Tuesday, Wednesday * `ddd` - Mon, Tue, Wed diff --git a/tests/destinations/test_path_utils.py b/tests/destinations/test_path_utils.py index 7ae7198492..b762fc7935 100644 --- a/tests/destinations/test_path_utils.py +++ b/tests/destinations/test_path_utils.py @@ -143,6 +143,42 @@ def dummy_callback2(*args, **kwargs): True, [], ), + ( + "{table_name}/{mm}/{ss}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('mm/ss').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{mm}/{s}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('mm/s').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{ss}/{s}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('ss/s').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{SSS}/{SS}/{S}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('SSS/SS/S').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/{SS}/{S}/{load_id}.{file_id}.{ext}", + f"mocked-table/{frozen_datetime.format('SS/S').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), + ( + "{table_name}/ms={SSS}/mcs={SSSS}/{load_id}.{file_id}.{ext}", + f"mocked-table/ms={frozen_datetime.format('SSS').lower()}/mcs={frozen_datetime.format('SSSS').lower()}/mocked-load-id.mocked-file-id.jsonl", + True, + [], + ), ( "{table_name}/{H}/{m}/{load_id}.{file_id}.{ext}", f"mocked-table/{frozen_datetime.format('H/m').lower()}/mocked-load-id.mocked-file-id.jsonl", diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index 339c4390bf..5e94d42748 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -272,7 +272,11 @@ def some_source(): "{table_name}/{load_package_timestamp}/{d}/{load_id}.{file_id}.{ext}", ( "{table_name}/{YYYY}/{YY}/{Y}/{MMMM}/{MMM}/{MM}/{M}/{DD}/{D}/" - "{HH}/{H}/{ddd}/{dd}/{d}/{Q}/{timestamp}/{curr_date}/{load_id}.{file_id}.{ext}" + "{HH}/{H}/{ddd}/{dd}/{d}/{ss}/{s}/{Q}/{timestamp}/{curr_date}/{load_id}.{file_id}.{ext}" + ), + ( + "{table_name}/{YYYY}/{YY}/{Y}/{MMMM}/{MMM}/{MM}/{M}/{DD}/{D}/" + "{SSSS}/{SSS}/{SS}/{S}/{Q}/{timestamp}/{curr_date}/{load_id}.{file_id}.{ext}" ), ) From a225a980814868d230c13f7f3dad895bd098f66b Mon Sep 17 00:00:00 2001 From: Jorrit Sandbrink <47451109+jorritsandbrink@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:51:41 +0400 Subject: [PATCH 29/41] mark scd2 child table test essential (#1265) Co-authored-by: Jorrit Sandbrink --- tests/load/pipeline/test_scd2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/load/pipeline/test_scd2.py b/tests/load/pipeline/test_scd2.py index 65a0742195..117f834778 100644 --- a/tests/load/pipeline/test_scd2.py +++ b/tests/load/pipeline/test_scd2.py @@ -199,6 +199,7 @@ def r(data): ] +@pytest.mark.essential @pytest.mark.parametrize( "destination_config", destinations_configs(default_sql_configs=True, supports_merge=True), From 9f04a1b66d814f7d997adfebead7093a1cffc0b1 Mon Sep 17 00:00:00 2001 From: Zaeem Athar Date: Tue, 23 Apr 2024 19:29:04 +0200 Subject: [PATCH 30/41] Segment Migration: Making Endpoint Configurable. (#1236) * Adding dlthub_telemetry_endpoint to RunConfiguration. * Adding dlthub_telemetry_endpoint to test_configuration. * Segment Changes: 1. In init_segment() adding checks for env RUNTIME__TELEMETRY_ENDPOINT. 2. Update _SEGMENT_ENDPOINT based on env variable. Set default value if None provided with default write key. 3. Adjusting header based on endpoint. * Accessing values through config. * fix minor things and add new endpoint to common tests * add new endpoint url to local destinations * Adding new endpoint url to all destinations. * Adding test for init_segment. * formating tests. --------- Co-authored-by: Dave --- .github/workflows/test_common.yml | 1 + .github/workflows/test_dbt_cloud.yml | 1 + .github/workflows/test_dbt_runner.yml | 1 + .github/workflows/test_destination_athena.yml | 2 +- .../test_destination_athena_iceberg.yml | 2 +- .../workflows/test_destination_bigquery.yml | 1 + .../workflows/test_destination_databricks.yml | 1 + .github/workflows/test_destination_dremio.yml | 1 + .github/workflows/test_destination_mssql.yml | 1 + .github/workflows/test_destination_qdrant.yml | 1 + .../workflows/test_destination_snowflake.yml | 1 + .../workflows/test_destination_synapse.yml | 1 + .github/workflows/test_destinations.yml | 2 +- .github/workflows/test_doc_snippets.yml | 2 +- .github/workflows/test_local_destinations.yml | 2 +- .../configuration/specs/run_configuration.py | 1 + dlt/common/runtime/segment.py | 45 +++++++++------ .../configuration/test_configuration.py | 1 + tests/common/runtime/test_telemetry.py | 56 +++++++++++++++++++ 19 files changed, 100 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test_common.yml b/.github/workflows/test_common.yml index 68c4768af6..ffe10966c6 100644 --- a/.github/workflows/test_common.yml +++ b/.github/workflows/test_common.yml @@ -13,6 +13,7 @@ concurrency: env: RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} jobs: get_docs_changes: diff --git a/.github/workflows/test_dbt_cloud.yml b/.github/workflows/test_dbt_cloud.yml index 98fa44d304..5b57dc77c3 100644 --- a/.github/workflows/test_dbt_cloud.yml +++ b/.github/workflows/test_dbt_cloud.yml @@ -19,6 +19,7 @@ env: DBT_CLOUD__API_TOKEN: ${{ secrets.DBT_CLOUD__API_TOKEN }} RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} jobs: get_docs_changes: diff --git a/.github/workflows/test_dbt_runner.yml b/.github/workflows/test_dbt_runner.yml index cb26a97b96..85cb98a040 100644 --- a/.github/workflows/test_dbt_runner.yml +++ b/.github/workflows/test_dbt_runner.yml @@ -16,6 +16,7 @@ env: DLT_SECRETS_TOML: ${{ secrets.DLT_SECRETS_TOML }} RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} jobs: get_docs_changes: diff --git a/.github/workflows/test_destination_athena.yml b/.github/workflows/test_destination_athena.yml index 81ef86f713..c7aed6f70e 100644 --- a/.github/workflows/test_destination_athena.yml +++ b/.github/workflows/test_destination_athena.yml @@ -19,7 +19,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR - RUNTIME__DLTHUB_TELEMETRY_SEGMENT_WRITE_KEY: TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"athena\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" EXCLUDED_DESTINATION_CONFIGURATIONS: "[\"athena-parquet-staging-iceberg\", \"athena-parquet-no-staging-iceberg\"]" diff --git a/.github/workflows/test_destination_athena_iceberg.yml b/.github/workflows/test_destination_athena_iceberg.yml index c1041be26c..40514ce58e 100644 --- a/.github/workflows/test_destination_athena_iceberg.yml +++ b/.github/workflows/test_destination_athena_iceberg.yml @@ -19,7 +19,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR - RUNTIME__DLTHUB_TELEMETRY_SEGMENT_WRITE_KEY: TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"athena\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" EXCLUDED_DESTINATION_CONFIGURATIONS: "[\"athena-no-staging\", \"athena-parquet-no-staging\"]" diff --git a/.github/workflows/test_destination_bigquery.yml b/.github/workflows/test_destination_bigquery.yml index 91ed395fb1..b3926fb18c 100644 --- a/.github/workflows/test_destination_bigquery.yml +++ b/.github/workflows/test_destination_bigquery.yml @@ -19,6 +19,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"bigquery\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_databricks.yml b/.github/workflows/test_destination_databricks.yml index eb98f24fd5..81ec575145 100644 --- a/.github/workflows/test_destination_databricks.yml +++ b/.github/workflows/test_destination_databricks.yml @@ -19,6 +19,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"databricks\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_dremio.yml b/.github/workflows/test_destination_dremio.yml index e021afb0c0..1b47268b59 100644 --- a/.github/workflows/test_destination_dremio.yml +++ b/.github/workflows/test_destination_dremio.yml @@ -17,6 +17,7 @@ concurrency: env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"dremio\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_mssql.yml b/.github/workflows/test_destination_mssql.yml index 57f83694dc..3b5bfd8d42 100644 --- a/.github/workflows/test_destination_mssql.yml +++ b/.github/workflows/test_destination_mssql.yml @@ -20,6 +20,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"mssql\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_qdrant.yml b/.github/workflows/test_destination_qdrant.yml index 1bc45ff643..938778fe9f 100644 --- a/.github/workflows/test_destination_qdrant.yml +++ b/.github/workflows/test_destination_qdrant.yml @@ -18,6 +18,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"qdrant\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_snowflake.yml b/.github/workflows/test_destination_snowflake.yml index ab55f2f18f..0c9a2b08d1 100644 --- a/.github/workflows/test_destination_snowflake.yml +++ b/.github/workflows/test_destination_snowflake.yml @@ -19,6 +19,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"snowflake\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destination_synapse.yml b/.github/workflows/test_destination_synapse.yml index 9ee48edc46..4d3049853c 100644 --- a/.github/workflows/test_destination_synapse.yml +++ b/.github/workflows/test_destination_synapse.yml @@ -18,6 +18,7 @@ env: RUNTIME__SENTRY_DSN: https://cf6086f7d263462088b9fb9f9947caee@o4505514867163136.ingest.sentry.io/4505516212682752 RUNTIME__LOG_LEVEL: ERROR + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"synapse\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\"]" diff --git a/.github/workflows/test_destinations.yml b/.github/workflows/test_destinations.yml index 3b445eea82..fed5c99fe1 100644 --- a/.github/workflows/test_destinations.yml +++ b/.github/workflows/test_destinations.yml @@ -25,7 +25,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR - RUNTIME__DLTHUB_TELEMETRY_SEGMENT_WRITE_KEY: TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} # Test redshift and filesystem with all buckets # postgres runs again here so we can test on mac/windows ACTIVE_DESTINATIONS: "[\"redshift\", \"postgres\", \"duckdb\", \"filesystem\", \"dummy\"]" diff --git a/.github/workflows/test_doc_snippets.yml b/.github/workflows/test_doc_snippets.yml index 70ecad3325..cb6417a4ab 100644 --- a/.github/workflows/test_doc_snippets.yml +++ b/.github/workflows/test_doc_snippets.yml @@ -17,7 +17,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR - RUNTIME__DLTHUB_TELEMETRY_SEGMENT_WRITE_KEY: TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} # Slack hook for chess in production example RUNTIME__SLACK_INCOMING_HOOK: ${{ secrets.RUNTIME__SLACK_INCOMING_HOOK }} diff --git a/.github/workflows/test_local_destinations.yml b/.github/workflows/test_local_destinations.yml index bb0a7a35f5..dfe8e56735 100644 --- a/.github/workflows/test_local_destinations.yml +++ b/.github/workflows/test_local_destinations.yml @@ -20,7 +20,7 @@ env: RUNTIME__SENTRY_DSN: https://6f6f7b6f8e0f458a89be4187603b55fe@o1061158.ingest.sentry.io/4504819859914752 RUNTIME__LOG_LEVEL: ERROR - RUNTIME__DLTHUB_TELEMETRY_SEGMENT_WRITE_KEY: TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB + RUNTIME__DLTHUB_TELEMETRY_ENDPOINT: ${{ secrets.RUNTIME__DLTHUB_TELEMETRY_ENDPOINT }} ACTIVE_DESTINATIONS: "[\"duckdb\", \"postgres\", \"filesystem\", \"weaviate\"]" ALL_FILESYSTEM_DRIVERS: "[\"memory\", \"file\"]" diff --git a/dlt/common/configuration/specs/run_configuration.py b/dlt/common/configuration/specs/run_configuration.py index b57b4abbdd..945d7ad2a9 100644 --- a/dlt/common/configuration/specs/run_configuration.py +++ b/dlt/common/configuration/specs/run_configuration.py @@ -16,6 +16,7 @@ class RunConfiguration(BaseConfiguration): slack_incoming_hook: Optional[TSecretStrValue] = None dlthub_telemetry: bool = True # enable or disable dlthub telemetry dlthub_telemetry_segment_write_key: str = "a1F2gc6cNYw2plyAt02sZouZcsRjG7TD" + dlthub_telemetry_endpoint: str = "https://api.segment.io/v1/track" log_format: str = "{asctime}|[{levelname:<21}]|{process}|{thread}|{name}|{filename}|{funcName}:{lineno}|{message}" log_level: str = "WARNING" request_timeout: float = 60 diff --git a/dlt/common/runtime/segment.py b/dlt/common/runtime/segment.py index ac64591072..224d7ab65a 100644 --- a/dlt/common/runtime/segment.py +++ b/dlt/common/runtime/segment.py @@ -23,24 +23,31 @@ _SESSION: requests.Session = None _WRITE_KEY: str = None _SEGMENT_REQUEST_TIMEOUT = (1.0, 1.0) # short connect & send timeouts -_SEGMENT_ENDPOINT = "https://api.segment.io/v1/track" +_SEGMENT_ENDPOINT: str = None _SEGMENT_CONTEXT: TExecutionContext = None def init_segment(config: RunConfiguration) -> None: - assert ( - config.dlthub_telemetry_segment_write_key - ), "dlthub_telemetry_segment_write_key not present in RunConfiguration" + if config.dlthub_telemetry_endpoint is None: + raise ValueError("dlthub_telemetry_endpoint not specified in RunConfiguration") + if config.dlthub_telemetry_endpoint == "https://api.segment.io/v1/track": + assert ( + config.dlthub_telemetry_segment_write_key + ), "dlthub_telemetry_segment_write_key not present in RunConfiguration" + + global _WRITE_KEY, _SESSION, _SEGMENT_ENDPOINT # create thread pool to send telemetry to segment - global _WRITE_KEY, _SESSION if not _SESSION: _SESSION = requests.Session() # flush pool on exit atexit.register(_at_exit_cleanup) - # store write key - key_bytes = (config.dlthub_telemetry_segment_write_key + ":").encode("ascii") - _WRITE_KEY = base64.b64encode(key_bytes).decode("utf-8") + # store write key if present + if config.dlthub_telemetry_segment_write_key: + key_bytes = (config.dlthub_telemetry_segment_write_key + ":").encode("ascii") + _WRITE_KEY = base64.b64encode(key_bytes).decode("utf-8") + # store endpoint + _SEGMENT_ENDPOINT = config.dlthub_telemetry_endpoint # cache the segment context _default_context_fields() @@ -95,10 +102,10 @@ def _segment_request_header(write_key: str) -> StrAny: Returns: Authentication headers for segment. """ - return { - "Authorization": "Basic {}".format(write_key), - "Content-Type": "application/json", - } + headers = {"Content-Type": "application/json"} + if write_key: + headers["Authorization"] = "Basic {}".format(write_key) + return headers def get_anonymous_id() -> str: @@ -170,22 +177,24 @@ def _send_event(event_name: str, properties: StrAny, context: StrAny) -> None: logger.debug("Skipping request to external service: payload was filtered out.") return - if not _WRITE_KEY: - # If _WRITE_KEY is empty or `None`, telemetry has not been enabled - logger.debug("Skipping request to external service: telemetry key not set.") + if _SEGMENT_ENDPOINT is None: + # If _SEGMENT_ENDPOINT is `None`, telemetry has not been enabled + logger.debug("Skipping request to external service: telemetry endpoint not set.") return headers = _segment_request_header(_WRITE_KEY) def _future_send() -> None: # import time - # start_ts = time.time() + # start_ts = time.time_ns() resp = _SESSION.post( _SEGMENT_ENDPOINT, headers=headers, json=payload, timeout=_SEGMENT_REQUEST_TIMEOUT ) - # print(f"SENDING TO Segment done {resp.status_code} {time.time() - start_ts} {base64.b64decode(_WRITE_KEY)}") + # end_ts = time.time_ns() + # elapsed_time = (end_ts - start_ts) / 10e6 + # print(f"SENDING TO Segment done: {elapsed_time}ms Status: {resp.status_code}") # handle different failure cases - if resp.status_code != 200: + if resp.status_code not in [200, 204]: logger.debug( f"Segment telemetry request returned a {resp.status_code} response. " f"Body: {resp.text}" diff --git a/tests/common/configuration/test_configuration.py b/tests/common/configuration/test_configuration.py index 5fbcd86d92..d723bb3759 100644 --- a/tests/common/configuration/test_configuration.py +++ b/tests/common/configuration/test_configuration.py @@ -541,6 +541,7 @@ class _SecretCredentials(RunConfiguration): "slack_incoming_hook": None, "dlthub_telemetry": True, "dlthub_telemetry_segment_write_key": "TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB", + "dlthub_telemetry_endpoint": "https://api.segment.io/v1/track", "log_format": "{asctime}|[{levelname:<21}]|{process}|{thread}|{name}|{filename}|{funcName}:{lineno}|{message}", "log_level": "WARNING", "request_timeout": 60, diff --git a/tests/common/runtime/test_telemetry.py b/tests/common/runtime/test_telemetry.py index e67f7e8360..bc9bf6b38a 100644 --- a/tests/common/runtime/test_telemetry.py +++ b/tests/common/runtime/test_telemetry.py @@ -1,4 +1,5 @@ from typing import Any, TYPE_CHECKING +from contextlib import nullcontext as does_not_raise import os import pytest import logging @@ -49,6 +50,61 @@ def test_sentry_log_level() -> None: assert sll._handler.level == logging._nameToLevel["WARNING"] +@pytest.mark.parametrize( + "endpoint, write_key, expectation", + [ + ( + "https://api.segment.io/v1/track", + "TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB", + does_not_raise(), + ), + ( + "https://telemetry-tracker.services4758.workers.dev/", + None, + does_not_raise(), + ), + ], +) +def test_telemetry_endpoint(endpoint, write_key, expectation) -> None: + from dlt.common.runtime import segment + + with expectation: + segment.init_segment( + RunConfiguration( + dlthub_telemetry_endpoint=endpoint, dlthub_telemetry_segment_write_key=write_key + ) + ) + + assert segment._SEGMENT_ENDPOINT == endpoint + assert segment._WRITE_KEY is not None + + +@pytest.mark.parametrize( + "endpoint, write_key, expectation", + [ + ( + "https://api.segment.io/v1/track", + None, + pytest.raises(AssertionError), + ), + ( + None, + "TLJiyRkGVZGCi2TtjClamXpFcxAA1rSB", + pytest.raises(ValueError), + ), + ], +) +def test_telemetry_endpoint_exceptions(endpoint, write_key, expectation) -> None: + from dlt.common.runtime import segment + + with expectation: + segment.init_segment( + RunConfiguration( + dlthub_telemetry_endpoint=endpoint, dlthub_telemetry_segment_write_key=write_key + ) + ) + + @pytest.mark.forked def test_sentry_init(environment: DictStrStr) -> None: with patch("dlt.common.runtime.sentry.before_send", _mock_before_send): From 6879a094b24c3569f783b57d00293f3635f14aa5 Mon Sep 17 00:00:00 2001 From: David Scharf Date: Wed, 24 Apr 2024 13:27:49 +0200 Subject: [PATCH 31/41] Make merge write-disposition fall back to append if no primary or merge keys are specified (#1225) * add sanity check to prevent missing config setup * fall back to append for merge without merge keys * add test for checking behavior of hard_delete without key * add schema warning * fix athena iceberg locations * add note in docs about merge fallback behavior * fix merge switching tests * fix one additional test with fallback --- dlt/common/destination/reference.py | 8 ++ dlt/destinations/sql_jobs.py | 109 ++++++++++-------- .../docs/general-usage/incremental-loading.md | 6 + .../load/pipeline/test_filesystem_pipeline.py | 83 +++---------- tests/load/pipeline/test_merge_disposition.py | 49 +++++--- .../test_write_disposition_changes.py | 6 +- tests/load/utils.py | 4 + 7 files changed, 132 insertions(+), 133 deletions(-) diff --git a/dlt/common/destination/reference.py b/dlt/common/destination/reference.py index 5422414cf3..2f9650a446 100644 --- a/dlt/common/destination/reference.py +++ b/dlt/common/destination/reference.py @@ -353,6 +353,14 @@ def _verify_schema(self) -> None: f'"{table["x-merge-strategy"]}" is not a valid merge strategy. ' # type: ignore[typeddict-item] f"""Allowed values: {', '.join(['"' + s + '"' for s in MERGE_STRATEGIES])}.""" ) + if not has_column_with_prop(table, "primary_key") and not has_column_with_prop( + table, "merge_key" + ): + logger.warning( + f"Table {table_name} has write_disposition set to merge, but no primary or" + " merge keys defined. " + + "dlt will fall back to append for this table." + ) if has_column_with_prop(table, "hard_delete"): if len(get_columns_names_with_prop(table, "hard_delete")) > 1: raise SchemaException( diff --git a/dlt/destinations/sql_jobs.py b/dlt/destinations/sql_jobs.py index 86eaa9236a..e7993106e1 100644 --- a/dlt/destinations/sql_jobs.py +++ b/dlt/destinations/sql_jobs.py @@ -145,7 +145,10 @@ def generate_sql( class SqlMergeJob(SqlBaseJob): - """Generates a list of sql statements that merge the data from staging dataset into destination dataset.""" + """ + Generates a list of sql statements that merge the data from staging dataset into destination dataset. + If no merge keys are discovered, falls back to append. + """ failed_text: str = "Tried to generate a merge sql job for the following tables:" @@ -382,68 +385,74 @@ def gen_merge_sql( get_columns_names_with_prop(root_table, "merge_key"), ) ) - key_clauses = cls._gen_key_table_clauses(primary_keys, merge_keys) - unique_column: str = None - root_key_column: str = None + # if we do not have any merge keys to select from, we will fall back to a staged append, i.E. + # just skip the delete part + append_fallback = (len(primary_keys) + len(merge_keys)) == 0 - if len(table_chain) == 1: - key_table_clauses = cls.gen_key_table_clauses( - root_table_name, staging_root_table_name, key_clauses, for_delete=True - ) - # if no child tables, just delete data from top table - for clause in key_table_clauses: - sql.append(f"DELETE {clause};") - else: - key_table_clauses = cls.gen_key_table_clauses( - root_table_name, staging_root_table_name, key_clauses, for_delete=False - ) - # use unique hint to create temp table with all identifiers to delete - unique_columns = get_columns_names_with_prop(root_table, "unique") - if not unique_columns: - raise MergeDispositionException( - sql_client.fully_qualified_dataset_name(), - staging_root_table_name, - [t["name"] for t in table_chain], - f"There is no unique column (ie _dlt_id) in top table {root_table['name']} so" - " it is not possible to link child tables to it.", - ) - # get first unique column - unique_column = escape_id(unique_columns[0]) - # create temp table with unique identifier - create_delete_temp_table_sql, delete_temp_table_name = cls.gen_delete_temp_table_sql( - unique_column, key_table_clauses, sql_client - ) - sql.extend(create_delete_temp_table_sql) + if not append_fallback: + key_clauses = cls._gen_key_table_clauses(primary_keys, merge_keys) - # delete from child tables first. This is important for databricks which does not support temporary tables, - # but uses temporary views instead - for table in table_chain[1:]: - table_name = sql_client.make_qualified_table_name(table["name"]) - root_key_columns = get_columns_names_with_prop(table, "root_key") - if not root_key_columns: + unique_column: str = None + root_key_column: str = None + + if len(table_chain) == 1: + key_table_clauses = cls.gen_key_table_clauses( + root_table_name, staging_root_table_name, key_clauses, for_delete=True + ) + # if no child tables, just delete data from top table + for clause in key_table_clauses: + sql.append(f"DELETE {clause};") + else: + key_table_clauses = cls.gen_key_table_clauses( + root_table_name, staging_root_table_name, key_clauses, for_delete=False + ) + # use unique hint to create temp table with all identifiers to delete + unique_columns = get_columns_names_with_prop(root_table, "unique") + if not unique_columns: raise MergeDispositionException( sql_client.fully_qualified_dataset_name(), staging_root_table_name, [t["name"] for t in table_chain], - "There is no root foreign key (ie _dlt_root_id) in child table" - f" {table['name']} so it is not possible to refer to top level table" - f" {root_table['name']} unique column {unique_column}", + "There is no unique column (ie _dlt_id) in top table" + f" {root_table['name']} so it is not possible to link child tables to it.", ) - root_key_column = escape_id(root_key_columns[0]) + # get first unique column + unique_column = escape_id(unique_columns[0]) + # create temp table with unique identifier + create_delete_temp_table_sql, delete_temp_table_name = ( + cls.gen_delete_temp_table_sql(unique_column, key_table_clauses, sql_client) + ) + sql.extend(create_delete_temp_table_sql) + + # delete from child tables first. This is important for databricks which does not support temporary tables, + # but uses temporary views instead + for table in table_chain[1:]: + table_name = sql_client.make_qualified_table_name(table["name"]) + root_key_columns = get_columns_names_with_prop(table, "root_key") + if not root_key_columns: + raise MergeDispositionException( + sql_client.fully_qualified_dataset_name(), + staging_root_table_name, + [t["name"] for t in table_chain], + "There is no root foreign key (ie _dlt_root_id) in child table" + f" {table['name']} so it is not possible to refer to top level table" + f" {root_table['name']} unique column {unique_column}", + ) + root_key_column = escape_id(root_key_columns[0]) + sql.append( + cls.gen_delete_from_sql( + table_name, root_key_column, delete_temp_table_name, unique_column + ) + ) + + # delete from top table now that child tables have been prcessed sql.append( cls.gen_delete_from_sql( - table_name, root_key_column, delete_temp_table_name, unique_column + root_table_name, unique_column, delete_temp_table_name, unique_column ) ) - # delete from top table now that child tables have been prcessed - sql.append( - cls.gen_delete_from_sql( - root_table_name, unique_column, delete_temp_table_name, unique_column - ) - ) - # get name of column with hard_delete hint, if specified not_deleted_cond: str = None hard_delete_col = get_first_column_name_with_prop(root_table, "hard_delete") diff --git a/docs/website/docs/general-usage/incremental-loading.md b/docs/website/docs/general-usage/incremental-loading.md index 28d2f862b2..e7a7faddb0 100644 --- a/docs/website/docs/general-usage/incremental-loading.md +++ b/docs/website/docs/general-usage/incremental-loading.md @@ -132,6 +132,12 @@ def github_repo_events(last_created_at = dlt.sources.incremental("created_at", " yield from _get_rest_pages("events") ``` +:::note +If you use the `merge` write disposition, but do not specify merge or primary keys, merge will fallback to `append`. +The appended data will be inserted from a staging table in one transaction for most destinations in this case. +::: + + #### Delete records The `hard_delete` column hint can be used to delete records from the destination dataset. The behavior of the delete mechanism depends on the data type of the column marked with the hint: 1) `bool` type: only `True` leads to a delete—`None` and `False` values are disregarded diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index 5e94d42748..c08bd488ef 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -29,30 +29,14 @@ skip_if_not_active("filesystem") -def assert_file_matches( - layout: str, job: LoadJobInfo, load_id: str, client: FilesystemClient -) -> None: - """Verify file contents of load job are identical to the corresponding file in destination""" - local_path = Path(job.file_path) - filename = local_path.name - destination_fn = create_path( - layout, - filename, - client.schema.name, - load_id, - extra_placeholders=client.config.extra_placeholders, - ) - destination_path = posixpath.join(client.dataset_path, destination_fn) - - assert local_path.read_bytes() == client.fs_client.read_bytes(destination_path) - - def test_pipeline_merge_write_disposition(default_buckets_env: str) -> None: """Run pipeline twice with merge write disposition - Resource with primary key falls back to append. Resource without keys falls back to replace. + Regardless wether primary key is set or not, filesystem appends """ import pyarrow.parquet as pq # Module is evaluated by other tests + os.environ["DATA_WRITER__DISABLE_COMPRESSION"] = "True" + pipeline = dlt.pipeline( pipeline_name="test_" + uniq_id(), destination="filesystem", @@ -71,54 +55,25 @@ def other_data(): def some_source(): return [some_data(), other_data()] - info1 = pipeline.run(some_source(), write_disposition="merge") - info2 = pipeline.run(some_source(), write_disposition="merge") - - client: FilesystemClient = pipeline.destination_client() # type: ignore[assignment] - layout = client.config.layout - - append_glob = list(client._get_table_dirs(["some_data"]))[0] - replace_glob = list(client._get_table_dirs(["other_data"]))[0] - - append_files = client.fs_client.ls(append_glob, detail=False, refresh=True) - replace_files = client.fs_client.ls(replace_glob, detail=False, refresh=True) - - load_id1 = info1.loads_ids[0] - load_id2 = info2.loads_ids[0] - - # resource with pk is loaded with append and has 1 copy for each load - assert len(append_files) == 2 - assert any(load_id1 in fn for fn in append_files) - assert any(load_id2 in fn for fn in append_files) - - # resource without pk is treated as append disposition - assert len(replace_files) == 2 - assert any(load_id1 in fn for fn in replace_files) - assert any(load_id2 in fn for fn in replace_files) - - # Verify file contents - assert info2.load_packages - for pkg in info2.load_packages: - assert pkg.jobs["completed_jobs"] - for job in pkg.jobs["completed_jobs"]: - assert_file_matches(layout, job, pkg.load_id, client) - - complete_fn = f"{client.schema.name}__%s.jsonl" + pipeline.run(some_source(), write_disposition="merge") + assert load_table_counts(pipeline, "some_data", "other_data") == { + "some_data": 3, + "other_data": 5, + } - # Test complete_load markers are saved - assert client.fs_client.isfile( - posixpath.join(client.dataset_path, client.schema.loads_table_name, complete_fn % load_id1) - ) - assert client.fs_client.isfile( - posixpath.join(client.dataset_path, client.schema.loads_table_name, complete_fn % load_id2) - ) + # second load shows that merge always appends on filesystem + pipeline.run(some_source(), write_disposition="merge") + assert load_table_counts(pipeline, "some_data", "other_data") == { + "some_data": 6, + "other_data": 10, + } - # Force replace + # Force replace, back to initial values pipeline.run(some_source(), write_disposition="replace") - append_files = client.fs_client.ls(append_glob, detail=False, refresh=True) - replace_files = client.fs_client.ls(replace_glob, detail=False, refresh=True) - assert len(append_files) == 1 - assert len(replace_files) == 1 + assert load_table_counts(pipeline, "some_data", "other_data") == { + "some_data": 3, + "other_data": 5, + } @pytest.mark.parametrize("item_type", ALL_TEST_DATA_ITEM_FORMATS) diff --git a/tests/load/pipeline/test_merge_disposition.py b/tests/load/pipeline/test_merge_disposition.py index bfcdccfba4..2924aeb6df 100644 --- a/tests/load/pipeline/test_merge_disposition.py +++ b/tests/load/pipeline/test_merge_disposition.py @@ -240,10 +240,17 @@ def test_merge_no_child_tables(destination_config: DestinationTestConfiguration) assert github_2_counts["issues"] == 100 if destination_config.supports_merge else 115 +# mark as essential for now +@pytest.mark.essential @pytest.mark.parametrize( - "destination_config", destinations_configs(default_sql_configs=True), ids=lambda x: x.name + "destination_config", + destinations_configs(default_sql_configs=True, local_filesystem_configs=True), + ids=lambda x: x.name, ) def test_merge_no_merge_keys(destination_config: DestinationTestConfiguration) -> None: + # NOTE: we can test filesystem destination merge behavior here too, will also fallback! + if destination_config.file_format == "insert_values": + pytest.skip("Insert values row count checking is buggy, skipping") p = destination_config.setup_pipeline("github_3", full_refresh=True) github_data = github() # remove all keys @@ -264,8 +271,8 @@ def test_merge_no_merge_keys(destination_config: DestinationTestConfiguration) - info = p.run(github_data, loader_file_format=destination_config.file_format) assert_load_info(info) github_1_counts = load_table_counts(p, *[t["name"] for t in p.default_schema.data_tables()]) - # only ten rows remains. merge falls back to replace when no keys are specified - assert github_1_counts["issues"] == 10 if destination_config.supports_merge else 100 - 45 + # we have 10 rows more, merge falls back to append if no keys present + assert github_1_counts["issues"] == 100 - 45 + 10 @pytest.mark.parametrize( @@ -291,14 +298,14 @@ def test_merge_keys_non_existing_columns(destination_config: DestinationTestConf if not destination_config.supports_merge: return - # all the keys are invalid so the merge falls back to replace + # all the keys are invalid so the merge falls back to append github_data = github() github_data.load_issues.apply_hints(merge_key=("mA1", "Ma2"), primary_key=("123-x",)) github_data.load_issues.add_filter(take_first(1)) info = p.run(github_data, loader_file_format=destination_config.file_format) assert_load_info(info) github_2_counts = load_table_counts(p, *[t["name"] for t in p.default_schema.data_tables()]) - assert github_2_counts["issues"] == 1 + assert github_2_counts["issues"] == 100 - 45 + 1 with p._sql_job_client(p.default_schema) as job_c: _, table_schema = job_c.get_storage_table("issues") assert "url" in table_schema @@ -589,8 +596,10 @@ def r(data): destinations_configs(default_sql_configs=True, supports_merge=True), ids=lambda x: x.name, ) -@pytest.mark.parametrize("key_type", ["primary_key", "merge_key"]) +@pytest.mark.parametrize("key_type", ["primary_key", "merge_key", "no_key"]) def test_hard_delete_hint(destination_config: DestinationTestConfiguration, key_type: str) -> None: + # no_key setting will have the effect that hard deletes have no effect, since hard delete records + # can not be matched table_name = "test_hard_delete_hint" @dlt.resource( @@ -605,6 +614,9 @@ def data_resource(data): data_resource.apply_hints(primary_key="id", merge_key="") elif key_type == "merge_key": data_resource.apply_hints(primary_key="", merge_key="id") + elif key_type == "no_key": + # we test what happens if there are no merge keys + pass p = destination_config.setup_pipeline(f"abstract_{key_type}", full_refresh=True) @@ -623,7 +635,7 @@ def data_resource(data): ] info = p.run(data_resource(data), loader_file_format=destination_config.file_format) assert_load_info(info) - assert load_table_counts(p, table_name)[table_name] == 1 + assert load_table_counts(p, table_name)[table_name] == (1 if key_type != "no_key" else 2) # update one record (None for hard_delete column is treated as "not True") data = [ @@ -631,16 +643,17 @@ def data_resource(data): ] info = p.run(data_resource(data), loader_file_format=destination_config.file_format) assert_load_info(info) - assert load_table_counts(p, table_name)[table_name] == 1 + assert load_table_counts(p, table_name)[table_name] == (1 if key_type != "no_key" else 3) # compare observed records with expected records - qual_name = p.sql_client().make_qualified_table_name(table_name) - observed = [ - {"id": row[0], "val": row[1], "deleted": row[2]} - for row in select_data(p, f"SELECT id, val, deleted FROM {qual_name}") - ] - expected = [{"id": 2, "val": "baz", "deleted": None}] - assert sorted(observed, key=lambda d: d["id"]) == expected + if key_type != "no_key": + qual_name = p.sql_client().make_qualified_table_name(table_name) + observed = [ + {"id": row[0], "val": row[1], "deleted": row[2]} + for row in select_data(p, f"SELECT id, val, deleted FROM {qual_name}") + ] + expected = [{"id": 2, "val": "baz", "deleted": None}] + assert sorted(observed, key=lambda d: d["id"]) == expected # insert two records with same key data = [ @@ -654,6 +667,12 @@ def data_resource(data): assert counts == 2 elif key_type == "merge_key": assert counts == 3 + elif key_type == "no_key": + assert counts == 5 + + # we do not need to test "no_key" further + if key_type == "no_key": + return # delete one key, resulting in one (primary key) or two (merge key) deleted records data = [ diff --git a/tests/load/pipeline/test_write_disposition_changes.py b/tests/load/pipeline/test_write_disposition_changes.py index 50986727ed..2a7a94ef6b 100644 --- a/tests/load/pipeline/test_write_disposition_changes.py +++ b/tests/load/pipeline/test_write_disposition_changes.py @@ -10,6 +10,7 @@ from dlt.pipeline.exceptions import PipelineStepFailed +@dlt.resource(primary_key="id") def data_with_subtables(offset: int) -> Any: for _, index in enumerate(range(offset, offset + 100), 1): yield { @@ -96,13 +97,10 @@ def test_switch_to_merge(destination_config: DestinationTestConfiguration, with_ pipeline_name="test_switch_to_merge", full_refresh=True ) - @dlt.resource() - def resource(): - yield data_with_subtables(10) @dlt.source() def source(): - return resource() + return data_with_subtables(10) s = source() s.root_key = with_root_key diff --git a/tests/load/utils.py b/tests/load/utils.py index 110c2b433d..2f20e91e69 100644 --- a/tests/load/utils.py +++ b/tests/load/utils.py @@ -226,6 +226,10 @@ def destinations_configs( DestinationTestConfiguration(destination="synapse", supports_dbt=False), ] + # sanity check that when selecting default destinations, one of each sql destination is actually + # provided + assert set(SQL_DESTINATIONS) == {d.destination for d in destination_configs} + if default_vector_configs: # for now only weaviate destination_configs += [DestinationTestConfiguration(destination="weaviate")] From 6432ed791131ae5ad78a02a3ee2642c1641a3da3 Mon Sep 17 00:00:00 2001 From: rudolfix Date: Wed, 24 Apr 2024 14:37:22 +0200 Subject: [PATCH 32/41] a note on scd2 incoming high ts change (#1273) --- docs/website/docs/general-usage/incremental-loading.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/website/docs/general-usage/incremental-loading.md b/docs/website/docs/general-usage/incremental-loading.md index e7a7faddb0..38d3215e68 100644 --- a/docs/website/docs/general-usage/incremental-loading.md +++ b/docs/website/docs/general-usage/incremental-loading.md @@ -242,7 +242,7 @@ In example above we enforce the root key propagation with `fb_ads.root_key = Tru that correct data is propagated on initial `replace` load so the future `merge` load can be executed. You can achieve the same in the decorator `@dlt.source(root_key=True)`. -### `scd2` strategy +### 🧪 `scd2` strategy `dlt` can create [Slowly Changing Dimension Type 2](https://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2:_add_new_row) (SCD2) destination tables for dimension tables that change in the source. The resource is expected to provide a full extract of the source table each run. A row hash is stored in `_dlt_id` and used as surrogate key to identify source records that have been inserted, updated, or deleted. A high timestamp (9999-12-31 00:00:00.000000) is used to indicate an active record. #### Example: `scd2` merge strategy @@ -307,6 +307,11 @@ pipeline.run(dim_customer()) # third run — 2024-04-10 06:45:22.847403 | 2024-04-09 18:27:53.734235 | **2024-04-10 06:45:22.847403** | 2 | bar | 2 | | 2024-04-09 22:13:07.943703 | 9999-12-31 00:00:00.000000 | 1 | foo_updated | 1 | +:::caution +SCD2 is still work in progress. We plan to change the default **high timestamp** from `9999-12-31 00:00:00.000000` to `NULL` +and make it configurable. This feature will be released with `dlt` 0.4.10 +::: + #### Example: customize validity column names `_dlt_valid_from` and `_dlt_valid_to` are used by default as validity column names. Other names can be configured as follows: ```py From edcedd504c2a22a57a9da378c5aa6bd7b611aeac Mon Sep 17 00:00:00 2001 From: David Scharf Date: Wed, 24 Apr 2024 15:19:57 +0200 Subject: [PATCH 33/41] Add some missing tests (#896) * add pydantic contracts implementation tests * add tests for removal of normalizer section in schema * add tests for contracts on nested dicts * start working on pyarrow tests * start adding tests of pyarrow normalizer * add pyarrow normalizer tests * add basic arrow tests * merge fixes * update tests --------- Co-authored-by: Marcin Rudolf --- tests/libs/pyarrow/__init__.py | 0 tests/libs/pyarrow/test_pyarrow.py | 191 ++++++++++ tests/libs/pyarrow/test_pyarrow_normalizer.py | 111 ++++++ tests/normalize/test_normalize.py | 42 ++- tests/pipeline/test_schema_contracts.py | 340 ++++++++++++++---- tests/pipeline/utils.py | 2 +- 6 files changed, 610 insertions(+), 76 deletions(-) create mode 100644 tests/libs/pyarrow/__init__.py create mode 100644 tests/libs/pyarrow/test_pyarrow.py create mode 100644 tests/libs/pyarrow/test_pyarrow_normalizer.py diff --git a/tests/libs/pyarrow/__init__.py b/tests/libs/pyarrow/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/libs/pyarrow/test_pyarrow.py b/tests/libs/pyarrow/test_pyarrow.py new file mode 100644 index 0000000000..f81b3d1b99 --- /dev/null +++ b/tests/libs/pyarrow/test_pyarrow.py @@ -0,0 +1,191 @@ +from datetime import timezone, datetime, timedelta # noqa: I251 +from copy import deepcopy +from typing import List, Any + +import pytest +import pyarrow as pa + +from dlt.common import pendulum +from dlt.common.libs.pyarrow import ( + py_arrow_to_table_schema_columns, + from_arrow_scalar, + get_py_arrow_timestamp, + to_arrow_scalar, + get_py_arrow_datatype, + remove_null_columns, + remove_columns, + append_column, + rename_columns, + is_arrow_item, +) +from dlt.common.destination import DestinationCapabilitiesContext +from tests.cases import TABLE_UPDATE_COLUMNS_SCHEMA + + +def test_py_arrow_to_table_schema_columns(): + dlt_schema = deepcopy(TABLE_UPDATE_COLUMNS_SCHEMA) + + caps = DestinationCapabilitiesContext.generic_capabilities() + # The arrow schema will add precision + dlt_schema["col4"]["precision"] = caps.timestamp_precision + dlt_schema["col6"]["precision"], dlt_schema["col6"]["scale"] = caps.decimal_precision + dlt_schema["col11"]["precision"] = caps.timestamp_precision + dlt_schema["col4_null"]["precision"] = caps.timestamp_precision + dlt_schema["col6_null"]["precision"], dlt_schema["col6_null"]["scale"] = caps.decimal_precision + dlt_schema["col11_null"]["precision"] = caps.timestamp_precision + + # Ignoring wei as we can't distinguish from decimal + dlt_schema["col8"]["precision"], dlt_schema["col8"]["scale"] = (76, 0) + dlt_schema["col8"]["data_type"] = "decimal" + dlt_schema["col8_null"]["precision"], dlt_schema["col8_null"]["scale"] = (76, 0) + dlt_schema["col8_null"]["data_type"] = "decimal" + # No json type + dlt_schema["col9"]["data_type"] = "text" + del dlt_schema["col9"]["variant"] + dlt_schema["col9_null"]["data_type"] = "text" + del dlt_schema["col9_null"]["variant"] + + # arrow string fields don't have precision + del dlt_schema["col5_precision"]["precision"] + + # Convert to arrow schema + arrow_schema = pa.schema( + [ + pa.field( + column["name"], + get_py_arrow_datatype(column, caps, "UTC"), + nullable=column["nullable"], + ) + for column in dlt_schema.values() + ] + ) + + result = py_arrow_to_table_schema_columns(arrow_schema) + + # Resulting schema should match the original + assert result == dlt_schema + + +def test_to_arrow_scalar() -> None: + naive_dt = get_py_arrow_timestamp(6, tz=None) + # print(naive_dt) + # naive datetimes are converted as UTC when time aware python objects are used + assert to_arrow_scalar(datetime(2021, 1, 1, 5, 2, 32), naive_dt).as_py() == datetime( + 2021, 1, 1, 5, 2, 32 + ) + assert to_arrow_scalar( + datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone.utc), naive_dt + ).as_py() == datetime(2021, 1, 1, 5, 2, 32) + assert to_arrow_scalar( + datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone(timedelta(hours=-8))), naive_dt + ).as_py() == datetime(2021, 1, 1, 5, 2, 32) + timedelta(hours=8) + + # naive datetimes are treated like UTC + utc_dt = get_py_arrow_timestamp(6, tz="UTC") + dt_converted = to_arrow_scalar( + datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone(timedelta(hours=-8))), utc_dt + ).as_py() + assert dt_converted.utcoffset().seconds == 0 + assert dt_converted == datetime(2021, 1, 1, 13, 2, 32, tzinfo=timezone.utc) + + berlin_dt = get_py_arrow_timestamp(6, tz="Europe/Berlin") + dt_converted = to_arrow_scalar( + datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone(timedelta(hours=-8))), berlin_dt + ).as_py() + # no dst + assert dt_converted.utcoffset().seconds == 60 * 60 + assert dt_converted == datetime(2021, 1, 1, 13, 2, 32, tzinfo=timezone.utc) + + +def test_from_arrow_scalar() -> None: + naive_dt = get_py_arrow_timestamp(6, tz=None) + sc_dt = to_arrow_scalar(datetime(2021, 1, 1, 5, 2, 32), naive_dt) + + # this value is like UTC + py_dt = from_arrow_scalar(sc_dt) + assert isinstance(py_dt, pendulum.DateTime) + # and we convert to explicit UTC + assert py_dt == datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone.utc) + + # converts to UTC + berlin_dt = get_py_arrow_timestamp(6, tz="Europe/Berlin") + sc_dt = to_arrow_scalar( + datetime(2021, 1, 1, 5, 2, 32, tzinfo=timezone(timedelta(hours=-8))), berlin_dt + ) + py_dt = from_arrow_scalar(sc_dt) + assert isinstance(py_dt, pendulum.DateTime) + assert py_dt.tzname() == "UTC" + assert py_dt == datetime(2021, 1, 1, 13, 2, 32, tzinfo=timezone.utc) + + +def _row_at_index(table: pa.Table, index: int) -> List[Any]: + return [table.column(column_name)[index].as_py() for column_name in table.column_names] + + +@pytest.mark.parametrize("pa_type", [pa.Table, pa.RecordBatch]) +def test_remove_null_columns(pa_type: Any) -> None: + table = pa_type.from_pylist( + [ + {"a": 1, "b": 2, "c": None}, + {"a": 1, "b": None, "c": None}, + ] + ) + result = remove_null_columns(table) + assert result.column_names == ["a", "b"] + assert _row_at_index(result, 0) == [1, 2] + assert _row_at_index(result, 1) == [1, None] + + +@pytest.mark.parametrize("pa_type", [pa.Table, pa.RecordBatch]) +def test_remove_columns(pa_type: Any) -> None: + table = pa_type.from_pylist( + [ + {"a": 1, "b": 2, "c": 5}, + {"a": 1, "b": 3, "c": 4}, + ] + ) + result = remove_columns(table, ["b"]) + assert result.column_names == ["a", "c"] + assert _row_at_index(result, 0) == [1, 5] + assert _row_at_index(result, 1) == [1, 4] + + +@pytest.mark.parametrize("pa_type", [pa.Table, pa.RecordBatch]) +def test_append_column(pa_type: Any) -> None: + table = pa_type.from_pylist( + [ + {"a": 1, "b": 2}, + {"a": 1, "b": 3}, + ] + ) + result = append_column(table, "c", pa.array([5, 6])) + assert result.column_names == ["a", "b", "c"] + assert _row_at_index(result, 0) == [1, 2, 5] + assert _row_at_index(result, 1) == [1, 3, 6] + + +@pytest.mark.parametrize("pa_type", [pa.Table, pa.RecordBatch]) +def test_rename_column(pa_type: Any) -> None: + table = pa_type.from_pylist( + [ + {"a": 1, "b": 2, "c": 5}, + {"a": 1, "b": 3, "c": 4}, + ] + ) + result = rename_columns(table, ["one", "two", "three"]) + assert result.column_names == ["one", "two", "three"] + assert _row_at_index(result, 0) == [1, 2, 5] + assert _row_at_index(result, 1) == [1, 3, 4] + + +@pytest.mark.parametrize("pa_type", [pa.Table, pa.RecordBatch]) +def test_is_arrow_item(pa_type: Any) -> None: + table = pa_type.from_pylist( + [ + {"a": 1, "b": 2, "c": 5}, + {"a": 1, "b": 3, "c": 4}, + ] + ) + assert is_arrow_item(table) + assert not is_arrow_item(table.to_pydict()) + assert not is_arrow_item("hello") diff --git a/tests/libs/pyarrow/test_pyarrow_normalizer.py b/tests/libs/pyarrow/test_pyarrow_normalizer.py new file mode 100644 index 0000000000..25871edd45 --- /dev/null +++ b/tests/libs/pyarrow/test_pyarrow_normalizer.py @@ -0,0 +1,111 @@ +from typing import List, Any + +import pyarrow as pa +import pytest + +from dlt.common.libs.pyarrow import normalize_py_arrow_item, NameNormalizationClash +from dlt.common.normalizers import explicit_normalizers, import_normalizers +from dlt.common.schema.utils import new_column, TColumnSchema +from dlt.common.destination import DestinationCapabilitiesContext + + +def _normalize(table: pa.Table, columns: List[TColumnSchema]) -> pa.Table: + _, naming, _ = import_normalizers(explicit_normalizers()) + caps = DestinationCapabilitiesContext() + columns_schema = {c["name"]: c for c in columns} + return normalize_py_arrow_item(table, columns_schema, naming, caps) + + +def _row_at_index(table: pa.Table, index: int) -> List[Any]: + return [table.column(column_name)[index].as_py() for column_name in table.column_names] + + +def test_quick_return_if_nothing_to_do() -> None: + table = pa.Table.from_pylist( + [ + {"a": 1, "b": 2}, + ] + ) + columns = [new_column("a", "bigint"), new_column("b", "bigint")] + result = _normalize(table, columns) + # same object returned + assert result == table + + +def test_pyarrow_reorder_columns() -> None: + table = pa.Table.from_pylist( + [ + {"col_new": "hello", "col1": 1, "col2": "a"}, + ] + ) + columns = [new_column("col2", "text"), new_column("col1", "bigint")] + result = _normalize(table, columns) + # new columns appear at the end + assert result.column_names == ["col2", "col1", "col_new"] + assert _row_at_index(result, 0) == ["a", 1, "hello"] + + +def test_pyarrow_add_empty_types() -> None: + table = pa.Table.from_pylist( + [ + {"col1": 1}, + ] + ) + columns = [new_column("col1", "bigint"), new_column("col2", "text")] + result = _normalize(table, columns) + # new columns appear at the end + assert result.column_names == ["col1", "col2"] + assert _row_at_index(result, 0) == [1, None] + assert result.schema.field(1).type == "string" + + +def test_field_normalization_clash() -> None: + table = pa.Table.from_pylist( + [ + {"col^New": "hello", "col_new": 1}, + ] + ) + with pytest.raises(NameNormalizationClash): + _normalize(table, []) + + +def test_field_normalization() -> None: + table = pa.Table.from_pylist( + [ + {"col^New": "hello", "col2": 1}, + ] + ) + result = _normalize(table, []) + assert result.column_names == ["col_new", "col2"] + assert _row_at_index(result, 0) == ["hello", 1] + + +def test_default_dlt_columns_not_added() -> None: + table = pa.Table.from_pylist( + [ + {"col1": 1}, + ] + ) + columns = [ + new_column("_dlt_something", "bigint"), + new_column("_dlt_id", "text"), + new_column("_dlt_load_id", "text"), + new_column("col2", "text"), + new_column("col1", "text"), + ] + result = _normalize(table, columns) + # no dlt_id or dlt_load_id columns + assert result.column_names == ["_dlt_something", "col2", "col1"] + assert _row_at_index(result, 0) == [None, None, 1] + + +@pytest.mark.skip(reason="Somehow this does not fail, should we add an exception??") +def test_fails_if_adding_non_nullable_column() -> None: + table = pa.Table.from_pylist( + [ + {"col1": 1}, + ] + ) + columns = [new_column("col1", "bigint"), new_column("col2", "text", nullable=False)] + with pytest.raises(Exception): + _normalize(table, columns) diff --git a/tests/normalize/test_normalize.py b/tests/normalize/test_normalize.py index 91997a921e..3891c667c3 100644 --- a/tests/normalize/test_normalize.py +++ b/tests/normalize/test_normalize.py @@ -6,6 +6,7 @@ from dlt.common import json from dlt.common.destination.capabilities import TLoaderFileFormat from dlt.common.schema.schema import Schema +from dlt.common.schema.utils import new_table from dlt.common.storages.exceptions import SchemaNotFoundError from dlt.common.typing import StrAny from dlt.common.data_types import TDataType @@ -601,7 +602,7 @@ def extract_and_normalize_cases(normalize: Normalize, cases: Sequence[str]) -> s return normalize_pending(normalize) -def normalize_pending(normalize: Normalize) -> str: +def normalize_pending(normalize: Normalize, schema: Schema = None) -> str: # pool not required for map_single load_ids = normalize.normalize_storage.extracted_packages.list_packages() assert len(load_ids) == 1, "Only one package allowed or rewrite tests" @@ -609,7 +610,7 @@ def normalize_pending(normalize: Normalize) -> str: normalize._step_info_start_load_id(load_id) normalize.load_storage.new_packages.create_package(load_id) # read schema from package - schema = normalize.normalize_storage.extracted_packages.load_schema(load_id) + schema = schema or normalize.normalize_storage.extracted_packages.load_schema(load_id) # get files schema_files = normalize.normalize_storage.extracted_packages.list_new_jobs(load_id) # normalize without pool @@ -708,3 +709,40 @@ def assert_timestamp_data_type(load_storage: LoadStorage, data_type: TDataType) event_schema = load_storage.normalized_packages.load_schema(loads[0]) # in raw normalize timestamp column must not be coerced to timestamp assert event_schema.get_table_columns("event")["timestamp"]["data_type"] == data_type + + +def test_removal_of_normalizer_schema_section_and_add_seen_data(raw_normalize: Normalize) -> None: + extract_cases( + raw_normalize, + [ + "event.event.user_load_1", + ], + ) + load_ids = raw_normalize.normalize_storage.extracted_packages.list_packages() + assert len(load_ids) == 1 + extracted_schema = raw_normalize.normalize_storage.extracted_packages.load_schema(load_ids[0]) + + # add some normalizer blocks + extracted_schema.tables["event"] = new_table("event") + extracted_schema.tables["event__parse_data__intent_ranking"] = new_table( + "event__parse_data__intent_ranking" + ) + extracted_schema.tables["event__random_table"] = new_table("event__random_table") + + # add x-normalizer info (and other block to control) + extracted_schema.tables["event"]["x-normalizer"] = {"evolve-columns-once": True} # type: ignore + extracted_schema.tables["event"]["x-other-info"] = "blah" # type: ignore + extracted_schema.tables["event__parse_data__intent_ranking"]["x-normalizer"] = {"seen-data": True, "random-entry": 1234} # type: ignore + extracted_schema.tables["event__random_table"]["x-normalizer"] = {"evolve-columns-once": True} # type: ignore + + normalize_pending(raw_normalize, extracted_schema) + schema = raw_normalize.schema_storage.load_schema("event") + # seen data gets added, schema settings get removed + assert schema.tables["event"]["x-normalizer"] == {"seen-data": True} # type: ignore + assert schema.tables["event__parse_data__intent_ranking"]["x-normalizer"] == { # type: ignore + "seen-data": True, + "random-entry": 1234, + } + # no data seen here, so seen-data is not set and evolve settings stays until first data is seen + assert schema.tables["event__random_table"]["x-normalizer"] == {"evolve-columns-once": True} # type: ignore + assert "x-other-info" in schema.tables["event"] diff --git a/tests/pipeline/test_schema_contracts.py b/tests/pipeline/test_schema_contracts.py index 2dba9d7f6d..579a6289cf 100644 --- a/tests/pipeline/test_schema_contracts.py +++ b/tests/pipeline/test_schema_contracts.py @@ -1,6 +1,6 @@ import dlt, os, pytest import contextlib -from typing import Any, Callable, Iterator, Union, Optional +from typing import Any, Callable, Iterator, Union, Optional, Type from dlt.common.schema.typing import TSchemaContract from dlt.common.utils import uniq_id @@ -9,6 +9,7 @@ from dlt.extract import DltResource from dlt.pipeline.pipeline import Pipeline from dlt.pipeline.exceptions import PipelineStepFailed +from dlt.extract.exceptions import ResourceExtractionError from tests.load.pipeline.utils import load_table_counts from tests.utils import ( @@ -20,23 +21,33 @@ skip_if_not_active("duckdb") -schema_contract = ["evolve", "discard_value", "discard_row", "freeze"] +SCHEMA_CONTRACT = ["evolve", "discard_value", "discard_row", "freeze"] LOCATIONS = ["source", "resource", "override"] SCHEMA_ELEMENTS = ["tables", "columns", "data_type"] +OLD_COLUMN_NAME = "name" +NEW_COLUMN_NAME = "new_col" +VARIANT_COLUMN_NAME = "some_int__v_text" +SUBITEMS_TABLE = "items__sub_items" +NEW_ITEMS_TABLE = "new_items" +ITEMS_TABLE = "items" + + @contextlib.contextmanager -def raises_frozen_exception(check_raise: bool = True) -> Any: +def raises_step_exception(check_raise: bool = True, expected_nested_error: Type[Any] = None) -> Any: + expected_nested_error = expected_nested_error or DataValidationError if not check_raise: yield return with pytest.raises(PipelineStepFailed) as py_exc: yield if py_exc.value.step == "extract": - assert isinstance(py_exc.value.__context__, DataValidationError) + print(type(py_exc.value.__context__)) + assert isinstance(py_exc.value.__context__, expected_nested_error) else: # normalize - assert isinstance(py_exc.value.__context__.__context__, DataValidationError) + assert isinstance(py_exc.value.__context__.__context__, expected_nested_error) def items(settings: TSchemaContract) -> Any: @@ -74,26 +85,51 @@ def load_items(): yield { "id": index, "name": f"item {index}", - "sub_items": [{"id": index + 1000, "name": f"sub item {index + 1000}"}], + "sub_items": [ + {"id": index + 1000, "SomeInt": 5, "name": f"sub item {index + 1000}"} + ], } return load_items -def new_items(settings: TSchemaContract) -> Any: - @dlt.resource(name="new_items", write_disposition="append", schema_contract=settings) +def items_with_new_column_in_subtable(settings: TSchemaContract) -> Any: + @dlt.resource(name="Items", write_disposition="append", schema_contract=settings) def load_items(): for _, index in enumerate(range(0, 10), 1): - yield {"id": index, "some_int": 1, "name": f"item {index}"} + yield { + "id": index, + "name": f"item {index}", + "sub_items": [ + {"id": index + 1000, "name": f"sub item {index + 1000}", "New^Col": "hello"} + ], + } return load_items -OLD_COLUMN_NAME = "name" -NEW_COLUMN_NAME = "new_col" -VARIANT_COLUMN_NAME = "some_int__v_text" -SUBITEMS_TABLE = "items__sub_items" -NEW_ITEMS_TABLE = "new_items" +def items_with_variant_in_subtable(settings: TSchemaContract) -> Any: + @dlt.resource(name="Items", write_disposition="append", schema_contract=settings) + def load_items(): + for _, index in enumerate(range(0, 10), 1): + yield { + "id": index, + "name": f"item {index}", + "sub_items": [ + {"id": index + 1000, "name": f"sub item {index + 1000}", "SomeInt": "hello"} + ], + } + + return load_items + + +def new_items(settings: TSchemaContract) -> Any: + @dlt.resource(name=NEW_ITEMS_TABLE, write_disposition="append", schema_contract=settings) + def load_items(): + for _, index in enumerate(range(0, 10), 1): + yield {"id": index, "some_int": 1, "name": f"item {index}"} + + return load_items def run_resource( @@ -106,10 +142,10 @@ def run_resource( for item in settings.keys(): assert item in LOCATIONS ev_settings = settings[item] - if ev_settings in schema_contract: + if ev_settings in SCHEMA_CONTRACT: continue for key, val in ev_settings.items(): - assert val in schema_contract + assert val in SCHEMA_CONTRACT assert key in SCHEMA_ELEMENTS @dlt.source(name="freeze_tests", schema_contract=settings.get("source")) @@ -130,7 +166,7 @@ def source() -> Iterator[DltResource]: ) # check items table settings - # assert pipeline.default_schema.tables["items"].get("schema_contract", {}) == (settings.get("resource") or {}) + # assert pipeline.default_schema.tables[ITEMS_TABLE].get("schema_contract", {}) == (settings.get("resource") or {}) # check effective table settings # assert resolve_contract_settings_for_table(None, "items", pipeline.default_schema) == expand_schema_contract_settings(settings.get("resource") or settings.get("override") or "evolve") @@ -147,7 +183,7 @@ def get_pipeline(): ) -@pytest.mark.parametrize("contract_setting", schema_contract) +@pytest.mark.parametrize("contract_setting", SCHEMA_CONTRACT) @pytest.mark.parametrize("setting_location", LOCATIONS) @pytest.mark.parametrize("item_format", ALL_TEST_DATA_ITEM_FORMATS) def test_new_tables( @@ -160,23 +196,23 @@ def test_new_tables( table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 - assert OLD_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert table_counts[ITEMS_TABLE] == 10 + assert OLD_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] run_resource(pipeline, items_with_new_column, full_settings, item_format) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 20 - assert NEW_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert table_counts[ITEMS_TABLE] == 20 + assert NEW_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] # test adding new table - with raises_frozen_exception(contract_setting == "freeze"): + with raises_step_exception(contract_setting == "freeze"): run_resource(pipeline, new_items, full_settings, item_format) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts.get("new_items", 0) == (10 if contract_setting in ["evolve"] else 0) + assert table_counts.get(NEW_ITEMS_TABLE, 0) == (10 if contract_setting in ["evolve"] else 0) # delete extracted files if left after exception pipeline.drop_pending_packages() @@ -187,21 +223,21 @@ def test_new_tables( table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 30 - assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert table_counts[ITEMS_TABLE] == 30 + assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] # test adding new subtable - with raises_frozen_exception(contract_setting == "freeze"): + with raises_step_exception(contract_setting == "freeze"): run_resource(pipeline, items_with_subtable, full_settings) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 30 if contract_setting in ["freeze"] else 40 + assert table_counts[ITEMS_TABLE] == 30 if contract_setting in ["freeze"] else 40 assert table_counts.get(SUBITEMS_TABLE, 0) == (10 if contract_setting in ["evolve"] else 0) -@pytest.mark.parametrize("contract_setting", schema_contract) +@pytest.mark.parametrize("contract_setting", SCHEMA_CONTRACT) @pytest.mark.parametrize("setting_location", LOCATIONS) @pytest.mark.parametrize("item_format", ALL_TEST_DATA_ITEM_FORMATS) def test_new_columns( @@ -214,8 +250,8 @@ def test_new_columns( table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 - assert OLD_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert table_counts[ITEMS_TABLE] == 10 + assert OLD_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] # new should work run_resource(pipeline, new_items, full_settings, item_format) @@ -223,24 +259,24 @@ def test_new_columns( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) expected_items_count = 10 - assert table_counts["items"] == expected_items_count + assert table_counts[ITEMS_TABLE] == expected_items_count assert table_counts[NEW_ITEMS_TABLE] == 10 # test adding new column twice: filter will try to catch it before it is added for the second time - with raises_frozen_exception(contract_setting == "freeze"): + with raises_step_exception(contract_setting == "freeze"): run_resource(pipeline, items_with_new_column, full_settings, item_format, duplicates=2) # delete extracted files if left after exception pipeline.drop_pending_packages() if contract_setting == "evolve": - assert NEW_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert NEW_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] else: - assert NEW_COLUMN_NAME not in pipeline.default_schema.tables["items"]["columns"] + assert NEW_COLUMN_NAME not in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) expected_items_count += 20 if contract_setting in ["evolve", "discard_value"] else 0 - assert table_counts["items"] == expected_items_count + assert table_counts[ITEMS_TABLE] == expected_items_count # NOTE: arrow / pandas do not support variants and subtables so we must skip if item_format == "object": @@ -250,46 +286,85 @@ def test_new_columns( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) expected_items_count += 10 - assert table_counts["items"] == expected_items_count - assert table_counts[SUBITEMS_TABLE] == 10 + expected_subtable_items_count = 10 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count # test adding variant column run_resource(pipeline, items_with_variant, full_settings) # variants are not new columns and should be able to always evolve - assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) expected_items_count += 10 - assert table_counts["items"] == expected_items_count + assert table_counts[ITEMS_TABLE] == expected_items_count + # test adding new column in subtable (subtable exists already) + with raises_step_exception(contract_setting == "freeze"): + run_resource(pipeline, items_with_new_column_in_subtable, full_settings, item_format) + # delete extracted files if left after exception + pipeline.drop_pending_packages() + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + # main table only does not get loaded on freeze exception + expected_items_count += 0 if contract_setting in ["freeze"] else 10 + # subtable gets loaded on evolve and discard + expected_subtable_items_count += ( + 10 if contract_setting in ["evolve", "discard_value"] else 0 + ) + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count + # new column may only appear in evolve mode + if contract_setting == "evolve": + assert NEW_COLUMN_NAME in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] + else: + assert NEW_COLUMN_NAME not in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] + + # loading variant column will always work in subtable + run_resource(pipeline, items_with_variant_in_subtable, full_settings, item_format) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + expected_subtable_items_count += 10 + expected_items_count += 10 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count + assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] -@pytest.mark.parametrize("contract_setting", schema_contract) + +@pytest.mark.parametrize("contract_setting", SCHEMA_CONTRACT) @pytest.mark.parametrize("setting_location", LOCATIONS) -def test_freeze_variants(contract_setting: str, setting_location: str) -> None: +def test_variant_columns(contract_setting: str, setting_location: str) -> None: full_settings = {setting_location: {"data_type": contract_setting}} pipeline = get_pipeline() run_resource(pipeline, items, {}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 - assert OLD_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + expected_items_count = 10 + expected_subtable_items_count = 0 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert OLD_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] # subtable should work run_resource(pipeline, items_with_subtable, full_settings) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 20 - assert table_counts[SUBITEMS_TABLE] == 10 + expected_items_count += 10 + expected_subtable_items_count += 10 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count # new should work run_resource(pipeline, new_items, full_settings) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 20 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count assert table_counts[NEW_ITEMS_TABLE] == 10 # test adding new column @@ -297,21 +372,54 @@ def test_freeze_variants(contract_setting: str, setting_location: str) -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 30 - assert NEW_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + expected_items_count += 10 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count + assert NEW_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] # test adding variant column - with raises_frozen_exception(contract_setting == "freeze"): + with raises_step_exception(contract_setting == "freeze"): run_resource(pipeline, items_with_variant, full_settings) + pipeline.drop_pending_packages() if contract_setting == "evolve": - assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables["items"]["columns"] + assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] else: - assert VARIANT_COLUMN_NAME not in pipeline.default_schema.tables["items"]["columns"] + assert VARIANT_COLUMN_NAME not in pipeline.default_schema.tables[ITEMS_TABLE]["columns"] table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == (40 if contract_setting in ["evolve", "discard_value"] else 30) + expected_items_count += 10 if contract_setting in ["evolve", "discard_value"] else 0 + assert table_counts[ITEMS_TABLE] == expected_items_count + + # test adding new column in subtable (subtable exists already) + run_resource(pipeline, items_with_new_column_in_subtable, full_settings) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + expected_items_count += 10 + expected_subtable_items_count += 10 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count + assert NEW_COLUMN_NAME in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] + + # loading variant column will always work in subtable + with raises_step_exception(contract_setting == "freeze"): + run_resource(pipeline, items_with_variant_in_subtable, full_settings) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + # main table only does not get loaded on freeze exception + expected_items_count += 0 if contract_setting in ["freeze"] else 10 + # subtable gets loaded on evolve and discard + expected_subtable_items_count += 10 if contract_setting in ["evolve", "discard_value"] else 0 + assert table_counts[ITEMS_TABLE] == expected_items_count + assert table_counts[SUBITEMS_TABLE] == expected_subtable_items_count + # new column may only appear in evolve mode + if contract_setting == "evolve": + assert VARIANT_COLUMN_NAME in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] + else: + assert VARIANT_COLUMN_NAME not in pipeline.default_schema.tables[SUBITEMS_TABLE]["columns"] def test_settings_precedence() -> None: @@ -339,14 +447,14 @@ def test_settings_precedence_2() -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # trying to add variant when forbidden on source will fail run_resource(pipeline, items_with_variant, {"source": {"data_type": "discard_row"}}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # if allowed on resource it will pass run_resource( @@ -357,7 +465,7 @@ def test_settings_precedence_2() -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 20 + assert table_counts[ITEMS_TABLE] == 20 # if allowed on override it will also pass run_resource( @@ -372,7 +480,7 @@ def test_settings_precedence_2() -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 30 + assert table_counts[ITEMS_TABLE] == 30 @pytest.mark.parametrize("setting_location", LOCATIONS) @@ -384,21 +492,21 @@ def test_change_mode(setting_location: str) -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # trying to add variant when forbidden will fail run_resource(pipeline, items_with_variant, {setting_location: {"data_type": "discard_row"}}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # now allow run_resource(pipeline, items_with_variant, {setting_location: {"data_type": "evolve"}}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 20 + assert table_counts[ITEMS_TABLE] == 20 @pytest.mark.parametrize("setting_location", LOCATIONS) @@ -409,29 +517,29 @@ def test_single_settings_value(setting_location: str) -> None: table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # trying to add variant when forbidden will fail run_resource(pipeline, items_with_variant, {setting_location: "discard_row"}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # trying to add new column will fail run_resource(pipeline, items_with_new_column, {setting_location: "discard_row"}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 + assert table_counts[ITEMS_TABLE] == 10 # trying to add new table will fail run_resource(pipeline, new_items, {setting_location: "discard_row"}) table_counts = load_table_counts( pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] ) - assert table_counts["items"] == 10 - assert "new_items" not in table_counts + assert table_counts[ITEMS_TABLE] == 10 + assert NEW_ITEMS_TABLE not in table_counts def test_data_contract_interaction() -> None: @@ -472,10 +580,6 @@ def get_items_with_model(): def get_items_new_col(): yield from [{"id": 5, "name": "dave", "amount": 6, "new_col": "hello"}] - @dlt.resource(name="items") - def get_items_subtable(): - yield from [{"id": 5, "name": "dave", "amount": 6, "sub": [{"hello": "dave"}]}] - # test valid object pipeline = get_pipeline() # items with model work @@ -485,7 +589,7 @@ def get_items_subtable(): # loading once with pydantic will freeze the cols pipeline = get_pipeline() pipeline.run([get_items_with_model()]) - with raises_frozen_exception(True): + with raises_step_exception(True): pipeline.run([get_items_new_col()]) # it is possible to override contract when there are new columns @@ -505,7 +609,7 @@ def get_items(): yield {"id": 2, "name": "dave", "amount": 50, "new_column": "some val"} pipeline.run([get_items()], schema_contract={"columns": "freeze", "tables": "evolve"}) - assert pipeline.last_trace.last_normalize_info.row_counts["items"] == 2 + assert pipeline.last_trace.last_normalize_info.row_counts[ITEMS_TABLE] == 2 @pytest.mark.parametrize("table_mode", ["discard_row", "evolve", "freeze"]) @@ -524,7 +628,7 @@ def get_items(): } yield {"id": 2, "tables": "two", "new_column": "some val"} - with raises_frozen_exception(table_mode == "freeze"): + with raises_step_exception(table_mode == "freeze"): pipeline.run([get_items()], schema_contract={"tables": table_mode}) if table_mode != "freeze": @@ -589,7 +693,7 @@ def items(): } pipeline.run([items()], schema_contract={"columns": column_mode}) - assert pipeline.last_trace.last_normalize_info.row_counts["items"] == 2 + assert pipeline.last_trace.last_normalize_info.row_counts[ITEMS_TABLE] == 2 @pytest.mark.parametrize("column_mode", ["freeze", "discard_row", "evolve"]) @@ -622,3 +726,93 @@ def get_items(): # apply hints apply to `items` not the original resource, so doing get_items() below removed them completely pipeline.run(items) assert pipeline.last_trace.last_normalize_info.row_counts.get("items", 0) == 2 + + +@pytest.mark.parametrize("contract_setting", SCHEMA_CONTRACT) +@pytest.mark.parametrize("as_list", [True, False]) +def test_pydantic_contract_implementation(contract_setting: str, as_list: bool) -> None: + from pydantic import BaseModel + + class Items(BaseModel): + id: int # noqa: A003 + name: str + + def get_items(as_list: bool = False): + items = [ + { + "id": 5, + "name": "dave", + } + ] + if as_list: + yield items + else: + yield from items + + def get_items_extra_attribute(as_list: bool = False): + items = [{"id": 5, "name": "dave", "blah": "blubb"}] + if as_list: + yield items + else: + yield from items + + def get_items_extra_variant(as_list: bool = False): + items = [ + { + "id": "five", + "name": "dave", + } + ] + if as_list: + yield items + else: + yield from items + + # test columns complying to model + pipeline = get_pipeline() + pipeline.run( + [get_items(as_list)], + schema_contract={"columns": contract_setting}, + columns=Items, + table_name="items", + ) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + assert table_counts[ITEMS_TABLE] == 1 + + # test columns extra attribute + with raises_step_exception( + contract_setting in ["freeze"], + expected_nested_error=( + ResourceExtractionError if contract_setting == "freeze" else NotImplementedError + ), + ): + pipeline.run( + [get_items_extra_attribute(as_list)], + schema_contract={"columns": contract_setting}, + columns=Items, + table_name="items", + ) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + assert table_counts[ITEMS_TABLE] == 1 if (contract_setting in ["freeze", "discard_row"]) else 2 + + # test columns with variant + with raises_step_exception( + contract_setting in ["freeze", "discard_value"], + expected_nested_error=( + ResourceExtractionError if contract_setting == "freeze" else NotImplementedError + ), + ): + pipeline.run( + [get_items_extra_variant(as_list)], + schema_contract={"data_type": contract_setting}, + columns=Items, + table_name="items", + ) + table_counts = load_table_counts( + pipeline, *[t["name"] for t in pipeline.default_schema.data_tables()] + ) + assert table_counts[ITEMS_TABLE] == 1 if (contract_setting in ["freeze", "discard_row"]) else 3 diff --git a/tests/pipeline/utils.py b/tests/pipeline/utils.py index 036154b582..569ab69bfc 100644 --- a/tests/pipeline/utils.py +++ b/tests/pipeline/utils.py @@ -12,7 +12,7 @@ from dlt.common.typing import DictStrAny from dlt.destinations.impl.filesystem.filesystem import FilesystemClient from dlt.pipeline.exceptions import SqlClientNotAvailable - +from dlt.common.storages import FileStorage from tests.utils import TEST_STORAGE_ROOT PIPELINE_TEST_CASES_PATH = "./tests/pipeline/cases/" From c57fe0e049a09ed9c1067681bad5894e9fe16d4a Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Wed, 24 Apr 2024 19:23:41 +0530 Subject: [PATCH 34/41] Updated SQL documentation for windows authentication. (#1251) --- .../verified-sources/sql_database.md | 8 + docs/website/package-lock.json | 395 ++++++++++-------- 2 files changed, 219 insertions(+), 184 deletions(-) diff --git a/docs/website/docs/dlt-ecosystem/verified-sources/sql_database.md b/docs/website/docs/dlt-ecosystem/verified-sources/sql_database.md index 48717474dd..27ab47b527 100644 --- a/docs/website/docs/dlt-ecosystem/verified-sources/sql_database.md +++ b/docs/website/docs/dlt-ecosystem/verified-sources/sql_database.md @@ -113,6 +113,14 @@ Here, we use the `mysql` and `pymysql` dialects to set up an SSL connection to a sources.sql_database.credentials="mysql+pymysql://root:@35.203.96.191:3306/mysql?ssl_ca=&ssl_cert=client-cert.pem&ssl_key=client-key.pem" ``` +1. For MSSQL destinations using Windows Authentication, you can modify your connection string to include `trusted_connection=yes`. This bypasses the need for specifying a username and password, which is particularly useful when SQL login credentials are not an option. Here’s how you can set it up: + + ```toml + sources.sql_database.credentials="mssql://user:pw@my_host/my_database?trusted_connection=yes" + ``` + + >Note: The (user:pw) may be included but will be ignored by the server if `trusted_connection=yes` is set. + ### Initialize the verified source To get started with your data pipeline, follow these steps: diff --git a/docs/website/package-lock.json b/docs/website/package-lock.json index 68bd25a5f9..3fa5ec429d 100644 --- a/docs/website/package-lock.json +++ b/docs/website/package-lock.json @@ -206,81 +206,17 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { "version": "7.22.9", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", @@ -327,13 +263,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz", - "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", "dependencies": { - "@babel/types": "^7.22.15", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -455,20 +391,20 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -610,17 +546,17 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.15.tgz", - "integrity": "sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } @@ -660,13 +596,14 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz", - "integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -737,9 +674,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.16", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz", - "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", "bin": { "parser": "bin/babel-parser.js" }, @@ -2083,19 +2020,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.15.tgz", - "integrity": "sha512-DdHPwvJY0sEeN4xJU5uRLmZjgMMDIvMPniLuYzUVXj/GGzysPl0/fwt44JBkyUIzGJPV8QgHMcQdQ34XFuKTYQ==", - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.22.15", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15", - "debug": "^4.1.0", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -2103,12 +2040,12 @@ } }, "node_modules/@babel/types": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.15.tgz", - "integrity": "sha512-X+NLXr0N8XXmN5ZsaQdm9U2SSC3UbIYq/doL++sueHOTisgZHoKaQtZxGuV2cUPQHMfjKEfg/g6oy7Hm6SKFtA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.15", + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2789,13 +2726,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -2810,9 +2747,9 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "engines": { "node": ">=6.0.0" } @@ -2832,9 +2769,9 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -4220,12 +4157,12 @@ } }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -4233,7 +4170,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -4412,12 +4349,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4997,9 +4940,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -5586,6 +5529,22 @@ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -5925,6 +5884,25 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", @@ -6099,16 +6077,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -6413,9 +6391,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -6588,9 +6566,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -6610,14 +6591,18 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6763,6 +6748,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/got": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", @@ -6862,11 +6858,11 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6902,6 +6898,17 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -8352,9 +8359,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "funding": [ { "type": "github", @@ -8501,9 +8508,9 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8922,9 +8929,9 @@ } }, "node_modules/postcss": { - "version": "8.4.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", - "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "funding": [ { "type": "opencollective", @@ -8940,9 +8947,9 @@ } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -9708,9 +9715,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -10990,6 +10997,22 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -11060,13 +11083,17 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11153,9 +11180,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -12495,9 +12522,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", From e0a7fe0d364465342227bb3b20d8e0304ffc53fb Mon Sep 17 00:00:00 2001 From: dat-a-man <98139823+dat-a-man@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:38:59 +0530 Subject: [PATCH 35/41] Updated autodetectors (#1253) --- docs/website/docs/general-usage/schema.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/website/docs/general-usage/schema.md b/docs/website/docs/general-usage/schema.md index a66552cb7f..989b023b01 100644 --- a/docs/website/docs/general-usage/schema.md +++ b/docs/website/docs/general-usage/schema.md @@ -222,7 +222,7 @@ and columns are inferred from data. ### Data type autodetectors You can define a set of functions that will be used to infer the data type of the column from a -value. The functions are run from top to bottom on the lists. Look in `detections.py` to see what is +value. The functions are run from top to bottom on the lists. Look in [`detections.py`](https://github.com/dlt-hub/dlt/blob/devel/dlt/common/schema/detections.py) to see what is available. ```yaml @@ -231,6 +231,9 @@ settings: - timestamp - iso_timestamp - iso_date + - large_integer + - hexbytes_to_text + - wei_to_double ``` ### Column hint rules From 43f2e8fd8da4dd8ab2f04246855af8241d061467 Mon Sep 17 00:00:00 2001 From: Will Raphaelson Date: Wed, 24 Apr 2024 11:18:00 -0500 Subject: [PATCH 36/41] adding images and wordsmithing to Prefect walkthrough (#1276) * adding images and wordsmithing * changing image location * fixing image name --- .../deploy-a-pipeline/deploy-with-prefect.md | 9 ++++++--- .../images/prefect-dashboard.png | Bin 0 -> 203166 bytes .../images/prefect-flow-run.png | Bin 0 -> 430165 bytes 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-dashboard.png create mode 100644 docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-flow-run.png diff --git a/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md index 6fbf0f0d16..f0cc29da87 100644 --- a/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md +++ b/docs/website/docs/walkthroughs/deploy-a-pipeline/deploy-with-prefect.md @@ -8,14 +8,16 @@ keywords: [how to, deploy a pipeline, Prefect] ## Introduction to Prefect -Prefect is a workflow management system that automates and orchestrates data pipelines. As an open-source platform, it offers a framework for defining, scheduling, and executing tasks with dependencies. It enables users to scale and maintain their data workflows efficiently. +Prefect is a workflow orchestration and observability platform that automates and orchestrates data pipelines. As an open-source platform, it offers a framework for defining, scheduling, and executing tasks with dependencies. It enables users to observe, maintain, and scale their data workflows efficiently. + +![Prefect Flow Run](images/prefect-flow-run.png) ### Prefect features - **Flows**: These contain workflow logic, and are defined as Python functions. - **Tasks**: A task represents a discrete unit of work. Tasks allow encapsulation of workflow logic that can be reused for flows and subflows. -- **Deployments and Scheduling**: Deployments transform workflows from manually called functions into API-managed entities that you can trigger remotely. Prefect allows you to use schedules to automatically create new flow runs for deployments. -- **Automation:** Prefect Cloud enables you to configure [actions](https://docs.prefect.io/latest/concepts/automations/#actions) that Prefect executes automatically based on [trigger](https://docs.prefect.io/latest/concepts/automations/#triggers) conditions. +- **Deployments and Scheduling**: Deployments transform workflows from manually called functions into API-managed entities that you can trigger remotely. Prefect allows you to use schedules to automatically create new flow runs for deployments or trigger new runs based on events. +- **Automations:** Prefect Cloud enables you to configure [actions](https://docs.prefect.io/latest/concepts/automations/#actions) that Prefect executes automatically based on [triggers](https://docs.prefect.io/latest/concepts/automations/#triggers). - **Caching:** This feature enables a task to reflect a completed state without actually executing its defining code. - **Oberservality**: This feature allows users to monitor workflows and tasks. It provides insights into data pipeline performance and behavior through logging, metrics, and notifications. @@ -61,6 +63,7 @@ Here's a concise guide to orchestrating a `dlt` pipeline with Prefect using "Mov 3. You can view deployment details and scheduled runs, including successes and failures, using [PrefectUI](https://app.prefect.cloud/auth/login). This will help you know when a pipeline ran or more importantly, when it did not. +![Prefect Dashboard](images/prefect-dashboard.png) You can further extend the pipeline further by: diff --git a/docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-dashboard.png b/docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..034588a78113f86d090747b7265eaef0eead0aec GIT binary patch literal 203166 zcmaG|1zc6j)(1RDN{4iJ2_gd0A#v#Ll>5!C`knV1zK~lO?y1T!P@7{Ocz3=n+ z+lRCF*?VTstXZ>Wt@Zzp!7@@JFW_g@v1}^jrRKyG=B_U{mb65z-AQK4C(<8tK7x+LxfMNn6V1REl;3Ja(3God0 zhJ3o80rlsT(3ly|{ycxi2Hc0>mlqTh1HR?;Z4C@8?ToGLpVKvQ0R^W_6jbb0BqccY ztt=RIKUnD*FgjaUKNW%Ca^?h1Eez~+iJdLXE$uj+xk-OL!3mr{{mev4{Ob{WGj389 zNf}~6D_a9%HpW+suSj{|iHV81Y(E%s$_c&yqd0KIO=@g!Z_UZXy7_WQsu9b%&%WD|7X$vxb@GXigpIJf>sv5JMDS?8k#=}|L4s=3UV<$ zb^Sj?@h>_5`V|l~4?GvszXy#6{u$dDGz0`6gqRS&f-~e^Ds+mXFkbgk4&HYBgE2); zjv#J!qo5q096tzB3~oCjCns8lGdqW04kVkMW7g{OlO@P4p|R2F_Tb?3q3$x}wUOb_ zcdvS?#nV8M^3|Ls%f%)d3^ZcM-yTQdd}G;SIpQ1eXkwiMwXX9bP~?2SJ+L5|n3$+B zxr?Amq9EXYdw36nn#fJZ^JHk{M7=@3J=!p#2v1hK5NI(q`MWs($7`+NM&yne5K5wd z|IRD%Ht-jr;BP_$O~P<8NCJbgT{K z2u+O^7fU1N_YJG#YZD|DiTJ)Z%owGI`TK_Xyaj1ByLJgN+%5fH{^ZNFkPWs+<(I?5TZfpo&8Y#<^BmI6LAz+=8gcu0d`G2bsfD9Q?j!?)=gI5J4 zl}P@nXxeb(QYi|nU0`Bny-tiy)h|yv!{28ddDp zsJ{Qq=~^=t(=*n~qY>)QQ}V%>bW(|F{ijC5>!Y87Y&S0$&L6LBq6UR&Fq?ggUONi? zy=f@rutK3&nsQ&IsD{&dKeax#FiNL!joHMLVnGkZgG7m&(AvN;iOl-oZ~nm)f`Rn5 z`?HuHEp`Si<@!Bw>wWTlKa8X&u8%)+dETj{5@|P%jqmSd_eTxvcVoys^A$KA&(9)p zsMFyv6?n?^m#|MJ?n@x)1WvRFmj~wbqoZj|XF0?PsqFarWd`Z*!YexN=q$O$N zBG4zh6tSUEl#-u!DO&&0hWQ`}jT@az=Uc}%4X6bpN_@B{MYxiL*lYmg12^R1Suc=< zM30dvlO5&Vh;nthL*c?J`CKgORWMh*=Np+0Ura0VQW<+Mw)o)tn~NHZkc-#8wx~o| z8dXa@4u98lpAic0mmy9E^Y9D&M_W%555*T`J=9S@%;eL!-Q38jDL)6J&{(PCL^julS)tCRnHv247p}a92UyjUYcRFz2FF;H%aHsws zCQCQj+tv#S2T;g*o~XWce1(z`lGm<;zxwfyk^kee4Fz^oh=Dz?*_PQXo}L!dP2)8d zqZsi#%GFjDlq+NEJCEE17$p4hfqPSv$&SGf9H@U2{U57}53EodSgX~Oz&2Q|=~J~b zHl6iQzB~oqr zkYA+LJgMttZDnt~q@-4BnU8FDzLPFjsYIZSoJ6Zgt6J$AP;z=xoiJXwB*UoLuxEzn zv{-N5uF?1TO(@~&%?sW>Z%Ds+mpXjBzv;v;qm@XyOc6~g?3<%^Wa#A>2NxGw5~IiM zMDfaN(VUxgDK?-?#=io!U)WvdZ4tQQgBXQcmGPkjnP-!842ywyU-H|?vzsqLv2@yE zguI@2Qq9ZMqR}h?ox$iLPN}pGw7kfuuMH6kd1$GmVyPwQFn?r==?_xbM93C1{sdtiW78+DZEC%+8ltXcLo8RrJ?WV$eWl0A-F4)(}I=OD~|?t ziKw<%(bCp4L$aJV`Z4dX&!_HL z^wz0o%5;mf<*~U%KeOg<_#xZ&0n7Ip8>73O;V=qy*B-QLHk;)O1jR>WCGcLtbZM?F%B} z&+F%R)qKsbX)MQ{77Y^vEgr67`?J;bPWl)v&c^}~1n)}X3YoN9s&>|{1cQ*`aVH9c zgMt{!if4k8{o;#f`xhFW9ApzpQnR%*)#-b0FAoPsvwA@AqP?@#yV;xA9%l~^_qXiS zx?a}?g(V$Kf0#E|1kg@_ndW+L^aikgLk&MyAq^DL(5HUgto@_;^xJ*ly~F&ir+MR8 zWThiL86+Ak0aVs|52{E)?lG7i*Wt8gK_lV^c2fj{nLwkU_u=<2Fbj7?{ zX-tF3@pn%9qq{@&=tdIhUxuyN zhY6-zMclMiUo7ZQsE&1>Y*x2^XkV=z<^jeyI{9%i-r2^&=6FA7CPxqoVhh=Xjhnc8$KZ)qYNLObhM33XK;bzZUh`T4rPCkA z9tR+2D3n#?8=@(_}X`e%Qf{LkU!tjp6-AWGwgBfg83rPT9R z5lLzcJ?_Tk`F^1RiO{Jl4hOATVV~4-bX*i*H(sLIcwYa;^X}?K7;d&M6rZz*t6}yc zszXFtO)!LL>E}21$969z93`Czm1;Sh>&>4GIsW#9;t1z*)KjZmMC(z{f+A=FHeM9_ z<6m3w8Z58)ELf71QNM}LU4*I9?RakyCRZzEVWtdp*&(Uf2JT|b)qhuhD>TSc$?=Q? z@m|&jM2Arhm;lkuoOwdon?MmMhK{RzBJ5{2Y0M$=>bgWeA zVRDM4=?~FEBcDC8JG8i`3q#0Opj6p)>aEx@Mv)c|b*P^s(dj38ZmMWcbmiOVxY0kc zvE=nQVQ|qytucL3v)z6Vb28ywIswg8FBD3s`91=_c=w^ftF=hGR;_fcFfSxYuhf|J zU9My@Nmh@0>02B5k!mxeN-$luB&CgpeXf+vV=|f9k;cD*#gAp6*=n=m%@*Thx#szL zy95PlZK-Ql znN2!~F1B9OM4neZ9Uc&bi;nyArVBk81go5a&pq$Mmz|?(b`0|Cdq$z}NFkpVH|3JM z9Fk|ITJW@Nchvc88Sk6Znb7VU>IZcTZydLD6|)w;$zSQX!fX`r8)Z6iSsOwZC$6WAA24C9y~{tqh`!pIk0IiUtjH?h4tK3HhU;=qeIZZi9nk z*y#Fzuv|5$$HV{YiBK7~N-W><=fDJaXH_`ijgP2VdxbWsPjQ;BKcC4)vtq6U&HfF- zfSHP%V1FFlsT5StTyzsbc{g`3X(FzQa&JPCYnUvSrV6vcyv?vC3CsqBTTN6wR zpE{cXr24y_H-+;v{oDp^x)>qTt=rghq50;jEF6Ug@di}Vsd7wZXkuKLvk3kA)DfFa zqYf(uSXvqs;;}L^!`z)Sl6O2&nbAKqxGRAZnN~t?tw7B4dW8UB_XF&)! z^}AEAxlTEuZ}CU;y(2Hz!u3#2?>5tuT^qK?a^xo==@x_Gw$MWlZag1z^FDv)bilsnRCC;;IG4;_iHvQjZ<54nf~FaU z-V&W`MUOF9Q#R>noi z2GYCwKl416d=N^$&g{PCOBaRNud zz%r_ug!e)f*`O)}8jj5GhE|DNfSSKRp@bX=)<+1%4f>Tv46Xzcsom!Vq%+qHKWlaLoLGN%g_>OZ@yHKx^c``M!HhjoVG$lqL4 zC-o#(cr!}!IsM=fLrdOetC38}^?E2zn#@9E9K48O)_-q?j)f${^6kr*R_2rMy??S$ zt&SYzqVmE+6_U=aFX{Ey`&`#h%q89AKU0~@mLN{Yy^J7#v+F`-rs7B(qpbHYzMDqT z>)xvrsmNY~12ncrvl1|9H8d(VuSQmK7LtC{V1HU@;$@;x$&xg52 zr}S*8RPkJ?)H5k=EFc1|Lx$w?nxQgm=j@+Xjq*kf#GsN+tH20($+SB1P>6^pms=I! zV7tk2#ai?JykOUS*y(T~fi3lhJtb0|HNS9e@*AJQeaOtokA9AuXB0|55x37=RVoC| zf_*%$Lx_~OHw8M6L|_@6J62TD1~mHBkv8fKzofZ+WXSy@N892-jhyeZ666MiOvjP7 zsQsPvs&?X^CLl7!mK#=gtYUY!=If*y-7{Xvp-w{ddU9M=1}6G2$J&lG5+|>g=?dC=HMeY$e4><&js(ZXH(kT`hvRvvPuLZz zi1L$MsAhr_Xla0uu&jaGW;I)jSMTdIIGrbz7!?6zSE^Xqpw%GP#`3+0KGAZ`e|^n=%h=l(u%QWKO!*Oo8(4#jZu%+UMCi z8!~xersx;A$Dzy*)w`46(0GgWDml^BmACS_wM!#`JP0uW}PUZUZ_+o+uwnE z^D1$-Aa0;~0KEujFD^<{^jW!6<4_{AAFI6Kp7rj|Xmj%7VLDS&xd0a?Xg@$F%+2vI z3WoNX!_C>Y0^FZDwfO|TYg>bJgfi~_Z^MmkWDrb<(K$2ZW~W*`r55D;epO~u@5g{h z$}b2}F<9F%Kk-A3!S}6b7O!>zpL1!y8>>UG3{mh{)LBNIc)#O%FC;KQ8Tnz+51A9? zsRxM3->YA*v0U41(57Bc^IB|`ZT->SorK1q>lM-^u+ zzAz{9Xo0+s+BKFFo7vJl`FZ-~YhCYE(8;B@XR3<{FY;fvf;TS)@pE5EQkbc>+7v#p z@5$h`7G1_7Bs@krrrx+NVKUadwV0*fpRF0SQ?2-rRPDbA(M>mf$g`T6ejAsgw-x$& zKv<13@L?%{?1%`G8eUftA>hk3?~70kYMj9_>e1-@sqxduu9C<17$?=48Xe)aP64Eg z>r+LFB1UFxS)A@yWvUbX%B5W?3gt|Wd(#b;16YH_cpgpd$#;}( zjh7b>3-9mXo2~_$sBVvmRm1wKs=Xc|ao)HVO}tse^1QRL0gna|Sj#`C83g(lX<69a zc5}Ww-O)rv~*_4B4b6@jcX3Zy)S?; zsFrwyp9a0dS75YFfW9mBofDj1<6FydnVqRZy7JhKS<*P60`1mPi8tB_7n>Z~bb)O{ zdI1P3!=XeQ!wehZm`+#ZVllm!vU!_4%3Y~H_xlq!hieU517!zW`Z8D5^~yol(>)+Kd3{xO`lvD--u$eq_an=q5$oMX3ybyu6rmCBt`L z0X?(3Ab980KV812_$6i+sd8sJKeWG%9o$HmMBt46*7fX?^Eia&HMkCb?Rl%gz)z%L z?FR&jM&j{s6hAz5x&@0P%P24Pd6Y6QcZs|Rtp$NjDG}4|7o^r0wSl7-GpO6_>5&X( z<-%B=@pi+2f64%ZU?8cxXRE5aGKFDjUYbv*M}BACACAjKWKz-KF#3E$Ql9e4AYvIE zg+{)9<=tSvvfJh73bRW{8qNIncMc0V5W2i6a2Owl9<+!s>351;sS{@A)44-1nKm3w ze-7&4A!5*$jh{ra`)#FP6oH|1gCXcK!=bZeSp##9VH}I1$wcIS-Vyc7ZyCebq|tVbO?A@U)E@?=v&%aFM~#3*`*ZWnq6Jlt4^S3F1F1aZQ8ErU#t&_0l+Usb|C6lF>hiEF49&8{ zArP>)Uf*nM)F#Bs6tV#k`cpz?i6WL_g?g3;n4LzGJ-EH}F=#*FcDvu<9JxKtg(Xr3 zDaFIiEr-;7$+piSlHUa4-2Uy?HDPhNVSnZgh#?+Cj5ic}k=dL*=(oHGU2R4X@S zGPnK>e2T75xD;*4@-XedS5%~c1zWil0j`hJ`kO|5x=;q>H&TuG&A&$<2RJ7Oj z;qsK0`I{rA`mwk%a(K@lY6bb0bU6pTni?>6240S2xcKmJ{+y7sSc3P`ZIDk`z`qV} z;?aS+V{|d!KN0RW5hy~=nVCitB@gp9g1_GT-*_(Zm)MY!uWOXA{>Y2`KLEYYM~Svv zFrp*4>lqBvPV6)~l~hP?6^GMrOTfZ_%rh~_T>NDuggZmc#;OAN`b7T=ko~JTY%++8 zi;Fx)8Mc8}FcY?mG%i&4lowG%@^66Z1*oa1351OKcYLipmB?>_W4u(?nimd)=c8Pz zMUt!6y9AP{e`)R|?nTOR8Gib(bC?5lAd!8X+Tn175upSykZ87Wh zr_R+vdCVs;X^UgPwI_ynhEG#t-xu`U|6}YA_?Ks^>0&q?I5(aXfDh*DDF8&mmS!>0 zEi5hT*Vr$?5eJfLr0HhD29OT9l=P>2X~naejDZmyJe!;@#?rg$Z7sJs<~%3i15^(m9_tRPVxcIz}I#ckt?; zfadT`leL}dPv0inbe5pPP6#oSyoj#;e;}?usU!1#-U&^f_p{spLIHyk@$dQe=l#Em zwmCu7$b|IEMgQq)|6?HjbyeD}b^B=iCdpCtDBrE007d z|C;vy-(Mj9s8F!4ho<`*8_Qq6!8k6R4&HjhIS%!y1b~#>!jteQt&?ETE0Q}SF_|$@o^RMEer~Z=X ze>KjXVwpnri;zzaDSGu)fABl8|9axzh3WrAw9y+~{s z##ATE?V3QcIu~pq&@cv$lX}=FR6e;mFO1@0jr=$NX&J^xGx|dK{Jy(PmR6TTALAXM}ZHO-)(@l}5w)5{UTkYbgKlY;-8R z6Fi3}V3;6)gh(zTghERcze9$c3 z0%j-)W{Bk-QLrFFGAJXv#|oie=x*{T*$z8XU{vz~4IkzYF#}8wWXw88n7AVz;ylp` zA~aYWC@vU(L?eJRFdxS!llE+}Qvq>aVdVr7p=+*o;RnTQ>q>nyG;3a{tKyZnK}%Z3 z2pXx@a=$n|zXr~q0IJS$4VO@5#e+`=u7VrR9|#^HSZ2BSGU652-;&{1-S6;l;7qr#sKI=Foq5U3D7f&Xx^IfBCp$oIDv00kGcNN%cI5j5gGtp8hU0@!37FyVj@ zgYF>{AnxDH!zey#RGE_DXi_l@xXi>5$z-;8AiKQX*8=3}0W_Af(djS_SPh@ULr@;y zPs_^HS?kt&J+{O$C^O64*N@ei#`VWsdnLISak^b_)w^AmMBd)d%I3-NKM^p#C%qmn zEr49qE;2^#i(>Zso?J`Z<}v-&{6}tF#<1a_`V6++uWawXP0U{>#3`uWJkm6n7A6^f zZkF0-dyJ!Bgg;G{Y9sh^tooft5HvnPD9_xlCX3a@1K>9{=IfUXrVh9v4|-Zw>JCK) z29qEAYXyVmu+?`N)Y+!Qc`^9%;o3FA?(R+}`Difb@;ET5qj*)~LKxIofAql8=IB)y zD3aU#lt81B!gskk%H(gS_>Bhev6DR!d7>&6K#d(ik)j>%CEs+L&s6j`)mIvP^FO2z z8JdTDpG+lf$#30jI$;J~hkW<~0qaiGa{g-0t-M+j;IEk7-<(R*C8n`8g6evrZb=QzN%p=~z1EU}0o=7;J_<4!)P6bg=%ME7C{#h{@tRANj( z?}_Yff9940j5*T&VACrj_opl}1IIp{Pgnc1+p{L4!Oxbe!tpfnYLPD(H*^B@i?pf* zR=YxtmNeeGzm*t11}G|h09lkmJ~s^McK_{n=Qf>&!qQp-9V0|?L=3y`81tCJP9ps2H-fiE8St)SO#X{s=rvgB|+ii)LG6~OwQ zp0!)5Q7@jx>s2zvjm;44useA>^E?g!mPy~3DI^ReFb!Pnza#g!v9Hj*$1ScpJ<|xI zRU6p^fR^vM0|US$*wxRLND`PVU~boh>> zJCR=NjID)a$6c*3*Zlh0XrU!fP7aCaqS!K>U6!<1Wq+aYD?YomXhw!GV)yl_p}M|5 z8i1%3*q-kw&Sd^f6r>SLU{a@tL#Hg;NyNlA+y6`iz4k=#u)R6Mq0wxJEmCReR6xyY z<967oGJNi2M6D&CEn2dhE%r$*hEh^*A}9tUj7g)8IGN32GR#Y(d|Xa}QN2MlhC-Tg zAmK4D5(b*);o{#G{hxNviE=ro1EL@?gph=%AJ8tba=or>a6*I{%}3{tlx! zi{p7bZg$xyz@GE~FY$>j(VyKX4-L;q4JDc05X0N@oEkw8R79_t>x-$4B0gX^__-Gd z)%1xTU}n$HowK|!Bz?4`e2fk|R9%~)p3o$Rjr2>(xF(WjDV3O zLydG}_|a0m(UGBjDeZT@?eY-Fyg?{O0g=O}Yo)%Ds`%MAfpVA7$w@XP6sJ+q6@h@W zovCN7EW#&?ZpY`YkOqpiE(60#^{U5(l1*1^*ef`%4U;I<%8g>(lyr%emS+)ilA1Sk z04f_p?f$m_1XF{8duEZyYpo+@C3P8 zDq_-K*6FOHYQnUIr~k1uwyySYezdV^>X_&K51we-;lO3*i&HKj;7Z}qzfDeHQ0H%O z+^2t2pqy}XzAN7B>M-u;^fg592h(qRmMPgLxa$T`P1onsSAKF?EkY$2d2Ws9@;hz< z{3bOe^N&=CLR|-f`20qz%E@!JR^Va6bTEM3SGh6Xhyt{?2p4v{N%H|tls;`JS}%=j z!Al^8;!%j8!n9a#r%ZRK6Ao9qs*o?&cfQMZX-Mu!lc^CH^nysY8g2`o9*J3`lht35 znEWY!L@uUxz5P|vb?#wzIaBa33uX*N36SWKjEq3GfeRh#1m| zTt}6*VQtx5UkzYsRGH6c(0rThUv3wyzCAFGTfvF94hVIp2kLe+I`XWpw_f=&-{1f? z&_AGLio0bj+I%05UxF5qEyGyHcDV+)CML?O&-;BX?*qf*1x{1_u4P_XJwV~y?CTtB zuuH2O3Yc8)M_E;E=#1)77yuZis}@~6Q@qvlxtLO7xB;)ndn1~C_pRBSRd531F2}{z zs3gsE;fK4^&u0hs0ZM2?sc#=sc`~ld77B9YAY2PRydooykmLLE05GlM-&4`23xefx#Ay^2 zD@fgZHW-JfuDmR)Y%H{cp1{|C9aeC}RnjxI28&o%6#!*SdME8dTHEx>O4)p)`H6J8 zU_4i|Ps_1($mrB|3y~mOP6~G=)>dld0}%H6Q#elV_JW@Hr~;q8gT%&9O4Kbxk-NUG z@#9R^EEutP&cDRDY-T`3SbgRBX4wp!xpjV1Eoi4at5EuoFOF6rq`s8NJnwo7q~T}= z%*8_v--pE#QAQ+`wh|IE>vvZa-Qy#j8FnO{=yZj|#jx$)j#UWTCB2=(3PL$XoU7W; zP$yAZH6nO7kT^!ecy_&L(x=z zh_S|;B0`hH09vz{&dcA>)66oo==zXSW9$#T1|W~(6SQ&rjW#N!1u@@?WMeIM#^3Sjn=Oqh+^T0c6eB?bI304P3iRmP(d6r$#80MyJ956h0)ERb!}Jxy2k?jG7DHDs;~o&g=UWJUU=6 z1(mmvcBEbx*kz%~<#n6gU^H1|3a0~W+rFj+Z0%-Zxt?iZV}_}pif=F)Wh`%rmcuN? z{3okCf6^h0ASpa{8i@qP)C&8iQqA<#J*@8oWOCU$m17=avnb#{#QvXJbnNIuc~ z3wOK;K>OY2_QzW6jHFe?yAqg|x}U!ZxACM^$@>^a_~x4&Z9Ic=yEYbdZfS;^qmt@) zxE$(c&=l=Ynu=M$6-$tf0xzR%zqzg0FYQI~2OWKZTd_W>I051go(n`UJ9Y8$3n;4> zhnG#!Ccdcrw!Inrky{S6Mg4tZFI1o2GTQbFq+IcQ78>aFd!2Pg}kkN1~3{C=({ z_m^uMGnGa@oQ^;W^4SDW*(Ichf^({)#hjXcS!?_%|HedNZTGWg*YimxoZ@~Tjcb(v z0<}7wv;%|wSd#}i+7lk)sH2&A3t0aP4hZ5qSFAP{Mzb1x%l5tX;3aZ^NjCe8*{EdR zl9>|87X&3p{A$xXdxxj!{Wr8dLvuwx&G@w1xMbed1ur|XNAZ>YD` z0eQ>*X|eVJf)^-Cg}MxT6L2Q!3a89Y*89+5_<(>&Uc9xp;kzeM?4omqzuw40`}f!m zmRUDn(x8ZmZf_sI4+k1^f?}Bh&<5>y^=e6JJRY3IGOb_qif-)P!{Ba-4K;u{IzIPv zI`7jPGb{p;$y#iD=U#s|0Q50j!1=Ymgg6D|YS~YN#c7CM4B(un$pWHvSDeFs5-`!(4BsdllIvm0wlSm&9hIa(YsxrG@&96HI?ygEqRh z#suRNkvZ$a1Q2BFe`Tz2+~=$u&<$?Yyx5yL2U}}gdZ0gQ5!q;a-$Hz>$GV;Ll39XD zbbEg5hCaWfq4*x<;*xf;XMM@V)oR!R4b}q6lN%v%>3O%wLGoat$DGj+b}zb4`WA-8 zzSm){^vex$>39i7jV9xUDe!!jNQAk3x=yeKImP)RRS6y(I=TB-fDk-S>|@Q}Ca6tC z77$u*jn*y>VHG@AeMk)uN@FY+8n(z^^;X<3IAwj9Dp_cE-8&lLpnnstUT<~EPcWVX zOr{WwJ^*kFx4j;7nYkG-yuP0|;c#8g0O-@r6Qd^vV0(@WAh;W$<+Z!i(dTknqpUpzJ_muR#Q<4u-Ed$>9E z<%G7<1?)itbwf7utj=B!3?~9wo=ha%7u_Z!Y!2_3abpm^ zhvJm}kp@PC1%s66wN3BY5Y~qeU)j&!t}PNMMaqy0=;*dsEm_{OAeMT~5T{{Ru+b^I7`UbZJ3G^oMRQSGrLAUM0>TKIV0@TmK+kA7ZmKJ@3L_RhHi`wTm4mg(7QN*xNwHqOr z{ksnI;drlEl5UkH)T`_^NXE~I7(QFj8#d-ul(fJ2w2h3+xzQg#`j$R-i1XY!oO3!z zzuf~L887echa+P7@L!M`EH+55^J6B1K*Qyg0HbRq@i9+3Td_ra;Kz>h!MB168oL)K zb5=Fj?=&0jCntc=wb+M3j2`-5@Wp8oC@ zhMk5g%YD;ATs`akg-Ur|9m~0fE|O7ft>^6p&{Np}g&XkrbL<;|eJg!WQU5bq!WgQs zS_&;Mh@bxbTl$133;dZV#NsFEp!ESFzNDSmOsN%7kZp>3%ifYS3T=A}a-ol!%l#}?gUQibKajov z9&AyHE_c$q%b@;-hyA*9DbHxWI+Ep_Qd#l*ElkVY&R}4Z9NN}QWi8=nS;E=KAF)ZC z7q_HioR-g}@Of{BzJBaSI{5TGk!PYIc)H0Y)G^(1vMEQYBy+IMM;mT&H91Hq7BKo) z2>F_u^3AN;l*x7mjUb^@YlOd1+!;XLhVkx%V9o~BG;yF{X z;Tge{8Its(yp~YAe$0_kn1x&PY^&x79hN|MeKh&yu(gLDz!OurIe+Yz-aw%rmrCU< zp1Z5E$)Z=;%Ad;I^R32-1s6_m+&6>Z>{41_*@_I z#`D#eNZggVx>1;MIm3Inc;h~ zM7(~?iDJnWD!nk7EXrp!n^j2Y`luUf6>8=~XL~e4LQsn8&=W>T2Uvp9L04Mg29eo#9;JinoEFO+q^i)1&Vr?P$qTsHw zYnb)C4pPW0E`OG2c;TK_VoQ)t^vb>6#hz+Uh*b>={>=QV6ke~z%pqxBi>jJ|CL4;m zX17R2K}3u?89=S2tdfb3WG4A(jOIxq@XfAllK=d_0A4sNxp6@(`;fm{DZDH3&#yITt`7j( zu-|^JNRQJUgMELqf}RXoTJ$O$l;<#tN6}%zWW}eID-ZP48WS=29?Sq zcLr4{s?7soGY=8odvAg=(b)=Un^>l;-G%QsKNidTk$caXT~5}qxT%@;ACXnx-Ib$Z zTte0{u_(#1pONI$v7yl!(l5fie5nzL2gSpU(AGcK+ex&j_Hxx7!XWed{@Z61CLCI& zDwp#N$@`o0^{aBSNf;Y@k}~=W#xnS~zSwd<)mHkstC@}Y+{jsldl5Q6r0`lr5I?*N z0DYr^WFo-jgC+s;;a7Lh?350~Aws%nK2hzoJtN?>x1G`rysC?W%>z#jYw}oTi&_n9 zcF&HmdKSkZ;4rs;#%9tgSm=>iY}M8t2+2`C*=U(pWj%q1l4#atK|-gkv27*6yUgklwE>vr7Gy2v?M6#InKi3vzx z*NI_Y^%_mt5WU+#dP*$Au7RS0(Zxxq&w1{apZT1_b*dCR-X6BP;=w0J!ed2$ep3j} z^o53I`UKFZ3y5G37aI$AK|kpn&DHt5^xO4rT#HJax?{#-XP8i>Q4fZlGq}ATe8pLh z3dITK`&-2QHAGmj>OSE?PT{?M7-NEy#D87M^lGX{S1<@xpkSKrOrmutzTJUn`F`=e z%SbDkPtxYQ)N&wrPx7W4hWdDlj9@!|s3c=EayZ|tAC8N)7-ej=imOM^ngyhi^e3cU z9`@?f9o!3+x9e3XT$_kH6rjq0pkjLyJ|#gso?aJ>^og$a?sCbC=Zz5uoWCr{23$YE%pUY zi`{x}$-uMIYKL7%%+tcei2Uh#@LQibR*;p&eh*5bei_3PBIExC%9~Kkh>3Fpl zbiwO%;G`KS@kP}4M64_1CTG$YI!%t>03pD9wn~-h*$Jj=n_AfF-jeG=&OpT8;~}eS*7|mi+->U7_`_igMPXx z>&*~|M-3uWCktS^7A~XK6@#jIXz(_?#Uf0dK(K=-@fIwA0SP+=W=lHkPV1%fC>>i5 zT3u0{e_QH>!XzSKMjHJ3A;01#=Ut7Tnx}G_eEMWbVF&B@O;yB(Y54B&X1ddhosM!| zG;0K?BP1E|u8~Ht$!vD?*rL}S)++&5lmK|l@wee59vXy^pl|3tm77C5+r(w3>wUb& zPFr7uLqvi7hD9cpa+A;BOK>r)2{hSFSvH%U#;2c;#cDS?O7g;fb}XT{5g>Sd;a0fg zf4o+7{=>@mGD~OlO8}?k4`A0Y1vvgu)#0wucUt=6#>c%3z!@Av&Jf+R1vVQfDPLeE zE0HKaL3G6UXnf|yL|z({3%rN9MnNi7&itU=&MXn6f?<;T3g}5M?XwE#`}D79S;T?Z z^kOJHt7rLZzA`WKUYk!lJ>>eUe2n7pa0MUh37_8p+VW+U8Y?!l|K>$7wBc}CekHaW zqgv7B8bUmqGsk4M4H*H#UL5}^|;nA>_IS7ZI_IsY;&|sGSlJ_5e7JCj_6zUnCP0RM_?5bY+U! z{?ZtQjqxACGI#BMtrJtQ)bPj4PL~Ioz((a}+kndYfnu%Z_jJgaLP8^6b>KDKD)V}H zPUaA=dXk7mU{6nudD0l|^rON;ljiMk)h&yjIfKS4Y4Lb!l3p!t$GtghX~E~n6aA9Z zgu4@kg&0vC(2TRsp1ac&DwT}PWbBt3JP;^5=X>2<*>J}JS)c9Qg)W3mV0)?0p#)RL zl!%5r-W$t;xX~>PcvCw`k4<78j3b^lm{;_akl3(o;?CV?`G@WR?z1w2TRv{(VrK&E z$@djZ1u*R0C0h&w4x|D7c`B>9i%Op1tW_ZCiN6Evurfmc1;XNasg>SnWiNaVhvw4b z(qi{gO3wl|ux0J8LSqlTz(9kI1RdEb(O`B&+#Px)DHh?WTThg|HjRh{=0P4@lboeE z3fD_glKzbW<)XbvY7u<$58^_OqHf1$?z=-EWn++iphmOngq)Nf-XvW~KluEWDbur7 z!Veat@5w+VQvj8`I@Y_cp55anOhOUor#ssJ@pTqZQFm+Gmy%LCr5i~BK}xz2knS$& z?h=&lmTu|pMmm&`?(Xh}Z{s=7dCq&@_k3%)Tr+^oIJ0N}_kG>h^}7#kaHSk2<%y7a zV`-Gfr`MJc{F3n8hc5%8sd7|~(Z19CX)d4~z{SvLN*j;kvkyBC{)(K(lGeM#L$v?k z+)%+YkVx;3@mQDb=ybc((R4B}QH(@KDV3VZVn_O%elBzw&1w6$p+Dyo&YL4M4{rR) zclmPF4RYoiN(o89>f<{TX6fOw67};rLlNSjplqM<#|(Mx;rCOnPRz!;3tuDabmLac z*vrr7)JwHuw_KMn!Z$VeOeF$P1^RX;t={M9TpKeMj|B^z*8Vt0>O(I9ZHgv4xZd;Q z^}_g@j~{V9BpJ!;Eb;e^jvn56FEt>yozH3Q9U^Kqxn~@{b|%u13D{kZ{d~CyxdDHO z4|E^v>;<~}5 zHy9N}H1QRFao`EFQm3~&VKE#CEuG3u0XP-I{(d>KrCRULXAQWI9`6Q}VxyXC9Z`Ww z`<6bjW8nF2E*zdlleAFqrddtxSx&`4S{oKFyN>xj(npQhdz z1fZ{Ge7QAP%VX+Ku?-{?1sQMX`w6RIl^*M7rTOxn!}}Q#?6zxp_IqRFuD4p*V!GSs z!pMCfIKl04qku)Pl}VSk(84j9ax!26Uw1Q$I0z5Ci`49mmrjfIc3#xhcpbld8xblb z$#kkN{d3yF-q~&Z+<~Ai;3P^zGFLX2d6Ic`!`gNfd3Ov{11gQ_raI=x1$5iW^*9=k zZk^yVY=}OGEQqz)Y1JpWg-dOl-yJx5ta*H{Y(*Oo2dhfN3$A%UtQyZsyDB$bxyxWqYKtr1pbmqKsGDQP^ z4uF{PYt4de&n01ZZy;8vNeH|k2F-V7*E2Yas3xO*&XoOc>KAwcXyTj zT9>sLiV`i$H2N6Ow-Y7TvUX2>`jmWa?ediV;XTyX13i zR|Q%Q1(PM}jKaZKBdY$gxbIY0zRQf*c$6XcSWVW1+V8iUcd{kkAq7M0=q?Q+2*2s# zpv%Jy+QKd&R-pEQ2DukvQnV>h!MHASB_MRc9rgIzKyW|Pkfnz7Z`34mAwq$8h_(%~ z6n-`l$ZiZBD}Wl^F+lqj_oU=wCl5aC!bk91o>RD^9}N#x%L?d4m5tpVbs#?l2AQqX ze-WS{boWJ5srk2pMw#QgZ#?>OvCU^7-!CTsQ%(5J(kBF;LrtyLlmxW+CuTowDdUk_ z@u4V^Y4xiemg2p$C;IaI<#3$;?IlqJ8b#0mx?#Tm&Bx!u%KUoASoRl5Du^E&NEQa! z*QMbTcs#z}ot#p7uZ(sPH$>?HN` z0)^rXA1Uq0SEw4s`q~jVb%Jc>;R9Zz6S=e$RsL$v>zlYv)KlIb0;?Q+iCUPwuVo?X3lB!7qFw~&6L)elRv2Mb_L?~n67$s zm9hZ@$FOR`-t2ncZ6xtJw_YBmQk*x45}+A`3LK3g5zL6sH&hotE1`6OqC=L?m8M6h z3K?Y>d@V;lsYxkuHz9$-5HF%flS5tvv#j)D@)KFWuP7x$s<%G1lp+}c%-I>DN;E*G zg{c4=%lWGx`Om)CpSxD@Q}%%*digk;q1EZ-Q7m7|WVk;&2U&ah2?I)mF0%$8 zv3xHW&C#|1YEJ3A+ddwdBeB8i_g^#f*tjaFX($Ln)Jy%ZcLlR6ML#O8ig2tn>dt85 zO$1;wXvL82hD(4v&y15<7r#(O0;5m+46U^8xHCWmDRCY08#nLy$al#7ZtQy-6Y&ZC zvH41g=z?}6OSEz^CRO_^(xcy@!J#lt4q#pKLF7EF_3AwR2b$Dt43djg7Jg&Y$KSW* z1e_bs|Cj5qcGYU$K5SJL*DnRK#Oj2zAnsnCE+}a<6N$cRpUH5KMsm~}~;ZD$SKoXb= z#!BQDO`&#$$zy<}RG|I8dZ?_R)6;_1Fd+|ytm~VB4-lA`q!QUI=wfLUW4eMjxL&|w zxtB$lrN_9sC_Gm-V7fR6LM&{MNK-%|xAw04-Mvigi1+c1B*3)E*~YN7BIqlfT9ryT zL|m}lW-|d19hN+~OiA&5P+`chB$nIE`I@@}pa%FfK*74_M_1bKP6%T$d@IxwL~>ea zQwO^9KsDy_@0?WEmL+DRJ?j02mrw28;dWmHgbLMF_K!HPyb(dlh6QFHF`pBYHV)fh zlW8LJbb~SCa=}d|f)5tnEP@?YTt}(ceYeEoW+m$HkLuq>#6N!M7Z6)pG1J<;23`+U zj_*}l%*%n{V6;I(8gD+HBv8B||#tyYa7JkelJM?r=>v z8<$F2?9IAqi+<-T{vaRc9s#Ojb=>3+d;Bq8=i{I|x`KSt(P=X^rWCxO8Gnw{SQ?hn z@Z=}V_PlTSU6%3d)e(qX(HI$cHLCHHl1}K&I;Qo3itWr@7QO$MUufcvO7PUd?{&Kb z4!gvf#II&&YM9ZWcTF>ugFbY&dl~zJkXeitTh>n{O0&VSr=UjbL(+wZ$#iBtAyxmd zS5gf@nZ$pOmQX=ARZ$fK{v<`S<-*;hILu^_?OHEct~7x|aYX^39zq2OVojpPTy_1S`IhrbRiWmO9aPIMWd`k$ARgt} z=}E~b@<2y%zu!X%-JDf5@(&wGgig;r^gkKpqqgm-&SH#y1DG!zOH>!6 zeNE2EByj+YjmjN+leCx9sxY0mmCDqaVcHhS=jaj4k;Lfc#Gck+lE54gSY%-9$c+Y|T)3k?<`@HxUE&&mrkQ ze&(Ndbx=P_2>*Zl_8`VB{rO<1q*71nub1IJZFy(`OVCZw96})U@Z%BB`sV+BAh2C8 zaYCq53j-XbbO`JJZ9jdg{e95?ge_orZY$&C?APrIvq+5h;&{qrud0lZ3FkT=&J083Knws(5( zek}%1?N0bvE63f%f1&50R6uio$c$^N|G3cLAfG85%n*o1@pAh3^bKm1ay-y*)J z+kbH}!kIwA^{@br| z`76x<#rTA-l#Cw~U+EO50NH0WGLa9IR`A?P7krRd5KRz#FNEv3J;NY7Z-HmXVDji3Wrk3u`Y$}|9kCw>yRvwfHM*nr71jC z-5w~`|7_&{`|ozDr?eY?zS!mF(e>tf(f|I5zm{Si{O~Cgdz?e@xH4HFRuwf4o~ z=12c0M8pHt8?%WQ*QYy%_rUhHRg?!zz6wXTJyzGbX%is4QeEr{!nuw&cxl&9)!^ zOb+m+!wb#5zkf(*S3sx6ee#h%0acOhHUZQsRpoY185Ccu=||uj zQGjtJ9mk+b4&vC+zmjUVb~q}GvGIEn$fXj;Xi8+W8bXNPO}u*Ae*leE#X6Zz^??c; zL~3_zK}7>ViiQ&LjvwoQocS~3$tG_2!^g#Eu`Du~!?qXr%+`VVw8CW62o%hL;jQ{r z;;}TbCZ_Zt%|qc}>Sbz?^*C0rkWEA>CO5wLQ?;_#!>FsYJkeFf^PPL%0rD<;PiH7L zb|FxPR-w6}CwUuq{f~Lc{Qt3uqda{Exf@h*%x={X!^IRwNY{DI#D(tfQAP94yN&vNW(eEXmWJ<&NI>6^O z{4zMVCFS>lX0~1;_FrW19LR%Rhg}2KeOHr5Mfqmx&&eEiO23VJ>4@CFDIY8}7Y=aR zf69%&&@KrXAL|MB18VCxDZm}YM?Lx-sHPwxW#ok<>ZK_Ck5{5C4O%B->%&LF#EBp# zwe^TR3b~z(=BeX&?i82)={Kq(e-wknw#Y2eb)XH$z#zVV3U{1qbbInjs}r{u(^$+@ z**@I4d=)Cu^?v-Jy84E1mF#bGHa|8N{Mt_^8x0B=WgZwcRgv3qVS6$JP^U zY;__!KeuE-D#YC7`Xr}<^G*gtzLtjH0NMs5tx>NNnMi-hyxWSVW7JyCGNeruygpv(Vo@y4 z(blh~DTuANGuqW(or|S0lxVhWP1_|$aRi~oBoO3rLcsqkI13&3j1D~|P-gu*LyV`* zX6z_Y5o}I+>nF?w>~I{}GuT~zkC1eH##=zgBKH1_wzbfl>xk_)OG;iZx0k?f3KILj z5rkv#YS*xbv~@!jmt+904N6T?3;9*yV^!*x{ zEpE}@4rl^f&Vd0QHTt(o+jH9%1Y`3x9*veh-$JZg{mjmkwP#jdFEl!ZBM3|RYlB0D zHwf|VE>fwzc(nWqM5VKoZeaxMntq`4n-vSz!wm?FwKTaD*;f7m3&sUBI_}5ey)$Qo z{OPHJFSrcrjcE%Gm4A?wRG73|6<=VHw{rlcqv*ROFgnIGI!IVhOjns8%KxN*|4IvK z?}eo7B`y7rvYVJ34!ZXDkP7w8P?u`Ly=R?S53an%3jMth<93UM%pVi&fiyAjByldo zuaJQtx$2V7+!zDfa*Idn&g-Xb2#7_6lNrOb+AYai{)+mEZx8*7#UkghzA%9n0%r-S zM&4>?AldclIzU5juy&&g3Qb1LZ`!)3Mo~lAIu1W zdJ%IZ^4~w$f=<#C4O21v=}K9G`i;fyd(G09@sqLq(G<4+c-}Myuj`XokUpHt81%rD zL5*THQ}VYpU!s7~BX5@|7nKpE6_&5JoPT~Ysr}AA!1u{hb>m=04hVv_TSvMklMgKN zQjD6ov^1oHJDc+j+ialjsMLx8zE@y&A=W{BeZMu~#51FXZ<5yf)cL5qn3ym{LvSn=wb$y!fFjQTFjrCS!a=(@;oUy@;;SNY z)dsAws95Fbq2E+2tS9&GDLu2EguIB)htRGUTP5@{$6P1B5*dwNY>LMW-wwQs3ldu} zVC;RNU96_4S*712O{?MUK~SXsg3_q)`h6C+RVp`)qdruTsf|NX9=Fv2-MSKiDpkbx ze2-C|`L6FGIf@qCR7qlH>WS=SDby{~);G`a58v5u6M3e+>LmH*^^W8}w z1I7iwLZ5AbvzalSC@7{U_L^=`Y#}n1go7|6jecR&**hYaW9ncsR+p~<+H*b50CpTV zzD|{Q*C?D>%a!m*ygg7YEj*_0W6WAY^BQk!b+mlIq zKYGv;UoXLmrHO6tD!UpSqy3 z@ z9aJwGk6mMiISshtAGbmf`9n%v=-P& zGYd(PNTcfbW~xkbR`Y%|*DGw>o_tW13MJrDJM5UG$?4>CR3oc4^P<6HyUTvJjAT*q z;qjp{t2!os=;`xE20?w<+#@RV(oM1xPz`rQj0cm);Z?0IuN-^dft(xzK@aH7n7H!B z_^5k62$!x7EJ&1ZVmBAt9@6GYr(vmJwXYydr#|s^3a8ZZ$t1!4zpX(?8};Lgl~G@K z--CM&*a`&OBp)rg0YzmSP*hrVjpy)RgU6ml0nz8k*$@i$=Xy~#QV476-Y@3l{@9vqKU@UqV-eg*uB;GSs`|x>wr~SApsfN=t%) zPP8$YTZ|u!O-loIYq^7g$sAx4XjuG}C3o^&&gSi>Lqg zfs}*vf`{O5Q0L2zMS_5V*)F3lf5!hJyOAQBJX;|`L|_|hyJYwA*6qS*YJRqPgFS3< z*VxHuAiL+_QyqgJ-#pOaVPVx`#{X#4(q(~sOb6%Bt-LTP^nGqhTgGJ6h-_?7F^d_&5Fx|Q3*Og>hB8Aj(7h=R^8()TWf9l{Od z+<>guiS5YB#PCOTE3=p5BMQkp4jP4xEsIiEbSi84h`VCMO7KFPT*QKYFHY1_67|rk z=a4P&3kx^}3;y<4YLab=7#AdrH@e>5odSA7}Padz0 z72K?NI5zj#Z0Gs*9lcQ2RXOL|>L5A|2zLgBGudElttOTg7=W@)t1$7RScUgSK!N=! zr%A2a9ab5@ZY1;mJSIf}#$&hXqJd9^&r5|vmu`j8^X!DQGy04tU>_Fe7$c9PGX8n#0h<-ZGRm}FRg}_X8d)T-+FxCRAn1w2vD~YZXPc6ssHj;M z4drFC_)Wx?Jh^1C=uy0Ilfm<5_`IQgXMhbYU;z3k`CA%97PdSSgEsNxpM&oR$-(hY@(U9~;W!4M@v*143P)W&SK_0iu4yR*K^WOVxY z#k_K0Cv}%(jNCF4u({cuZOLL1r`)dpN)kmb&PHPYYz?wk>jRgWYD}Cx6840T+rvZ9 zjO2z-E(TF=l$9Uq`&>J3$kjjdEvFK?8~#dEtZXF92y%1T zOc^{0b2IF<$%seT26n#9eL{d|i;{>XIL5Cubkk~OS!&0RM4M2X?;&F&M}+UBg1pLL zm|?{7W3-yQ5gz7KM;;IF9G*EcuC!F5`fjw7+~7gxtQ>L2_w56m;e1?5nJU zyDZg7`*AqZ#$o5}5EIsWc$CyZ*!bN5i~v;Ua!|{3GC-(27tK*w#?oot%WsoN1XTv{ z3UtUV%fBA#k08}a1eq%I5j@;xkP8Ktcg$T(VXq!pv~aW z8hCj*okD0So(|tCB%f6b;&#&?{VGHFv1rFuH^rkA21&(D4WpqG(gcodTsubwcWcu#s0-Y3;X3pvx>twB10xw5dMw(eZ2WSEL(((L)TPXh?t1 z#T*bAxxP!?NuI5LbVdo-JB30Nmg!P&&TR?TOUd2rlGjuyrLVXoDcE$0SY7F0=12hVxf3iG@NNpV?OWWa0G7ja{oK`DAwKr2L z@wr0JQ+M?A>A6McNSC%%j}?rIXV3$ip_}Z~R7@F%$kcC8q{40JcHm~>+c+*awbI-M6)h~8e<4Pm zlr>#Q9;M8GLaH!6jDZeL?#fp%Wgly$Wf5IbPw_T>r)q6-;YBpj> zTd_s+%~9&q^QaZNYpk%ziB%ssPpR7?Bq6ImEzN)ZA!a~dxIqMav2LB;KO2pA9p%nmlD32KU2nS}3yHUm=$I z6AZDGoRH_%xf<6o{0}Y5ni7RB5S_{|59dCLQcww#JChgP_{sGc{;X4&+m4PLMH!B` z{6jyg;d*{Os{@`^Ca#QSSnAG7|9Mv@CwJg?@UpLHgrDH2eseBxiz$sX`wPJJkMp%? z+UM}A+gnj>g|7oFLP%KP<1t0{*%)bv5D}i+9MXpA%Xx><%JyBVki3jTYB!Ix!=GQ2 z{Ow5Xf4&eUXaSCyU?PkGINSwzKssOGXfs{ZpN5n`HyIX)G%uFsYI2l(J6>Uva`24u8(Ri0R-iL6d^_d&>P19ok30uOYl1OoxWAZGwz!&4HoLe= zUQ`ADl&IG#Rae&XtPi-mzvQj>bgT!uca@G@j?4zV#HFoT$_UL0A1@9c)hT5XNNcSY zZMIiEBgjM@O+L1^7`U=U1MqjEc9HPZY@Aqi z@o;x12k^-#UN0UyZL5JA3$8VwgfMsjAQ?I1!=YCci?TF_(?K#Yiv8NX?`#VgOyKqM z?2FrX;V|Q|^V*-L1r5u}q1!Qe8F7F<@4QScy(>4Vyt=-gwg8gbFcTmvSM1iIyFivU z$$bFqc@)znK;<(S$?VG5DGqw-?E7L2d?ebRn`b%Co`04#TRz0{^l7S2E z)qLF=(!&H200Nyg=} zxun9NPfpVvocaysrkH5ZGBU$Z(ZF-2v1*ytsIgcq@t9i#1=i~?urJGco&tS0m%Fs% z1(PwmlckOJLurg@ArD5`6YGQbKEZi7v3B~I-cY=#a3<6c5Z}YteVY}kRA&L6yYy1Pp7`B)gz%;h0!+Q1C-!$O3n&o|9`q<={&GFZq;lBK3f=NO9A3 zZZ@7nFP0rMOpMEeS*rJL=aKtdj&KhV4|kO^=N09=7d=D@_;b}}weH|p30Y18;sk&q zf=oC9NEd@x81Ha*E4EpTOy67^hjy(*{D2Y+P2hGB$m3KHTA#y#WkN0WxV@CTMu;VM z3)7G*yf6bAkj1s*8TF4F!dSTi1M$~dcd@Vyb6}KBVS1hYw(a&pR_)pLtv8SeG}Ul3 z;4Yu#fZSjdsW1YrVUgG|hxbuDAcULT7@>!o1@|Tc!;vh%O=p0t2+j@PbL8s}ED00L zK9v0|Lk1CReQ6Q89isio^{3;BsONQFC>UOBnU@s$=Y44JA-6|!C*gIZxQ%|Q+yc7I zJ3=bUnX{Le7`ho?Tot!!i@Wbdp;~nih#x6llKswYL4_m15IpO-lz4m~O1yt|z4&;7 z(AiZcu}mtwer?V{;pHs==J{xN!{{eX6t}->2M1n%XrJ>%qf>f<_mW-CVt(-e9e5Og z5@6<)-sQ*s3<5@v;=#F#mV24G;+`l{eaNu;8H5aEKyCvL`#4n63cpuNbN#VfVR*l< ztKLCA1C3qJ#5Y(EB5MMh{ac<2bNWK%-AaAc=FA5h29%I6d=45QinGMIpPz-FewR|G zMypH7Y}WHdXz(pOHFMbQ1GC|B^m1rS>!ECNt(OHdP=K6X{*Ym}wk2zFA|k&%c?h+A zXNf%xMi|UByDe5@Lb+Y;=yqlqa|a26{dZ$;Br9o98N4kVj(h9{A+ZZu`HSh1T9Uca zR%QeXKoVz6dYGaH{-PKMGvz|e@8j%eqd(MOm(PcPv>Fa+bLEIfcSF+t1dz_B=p0Pk z*Ro7O?{wRxw0lTwh=Y?EK>$SIvsIT8k-VKyfOI=O?E|5+e6YlA*AndGqmXD@Uo-jrrq+iO1`a*8+i7V zl}fJ_la@UJd%_*_3~XgZ4jiGhTpFwNsm4POxd%foryIv_bA${AKO`^*mX?;P91Nhq z0R#>yZs@mwmCemkb-1a%5KJ;5y1E+&kP{qCeNPMMXOoUC0Ne$H274dbZiKVZjI`;} zG%UI^cAMJEM$>y>RL&M1TKIjuFAYq0wKwjoshL9AGN|a55QeR&kH^i-7B?@R+|v^- zH(DdlYu~B7zdRU~;l-P9x70kdUh72#a)YBs7&iT#xR&!WOuXgbB9!{WoI!9FL&{Hx zB3gi{${Ygog~E(GnvuiBi7();wv2xtkIL+Ww6!Es1XupjwO$)TMKf{{M=BE(1T z6Qxw!(7|`IL3p;1iQMrc+9KcZbaoxy21K?l(y|q49j5J%On;Fi`dah+C7#*c4@ejU z946Cbtv2v!QDk@L9G7>+$HCFj#_1#&xGw`HD#zw(`7#~;dEt;I)5pg3-B+zNiq)WG!|QAU16G(;bmgvh28ImVsEGPnJZ{I=(Jb5J z6ct^`Dg@?kf2z2ER+EqtuG268)F23jj2T&8LhcrTj&`rz(ECEm?Q7?&tkeladqTZ64&fO<5I4RT(yKDrrhLR!$cj%q!TK^ zei#W?UD^Q!b@m`y;4tA{93t^>9fhE?A6}(fU~e*))1E@`q(m%Dp0A!P88d(00&p2h zI{{KrJum~FZO;q7Vl859bbO?!HfyEHm&=J_;tg}+r3lXwd&aO&eSdQ)JF;Io&t-U3 z#@IagTow=J(hDF)Xe)0GZsQm04lG+baPWA`jfP-`S=pdKQ=c(FY5UHYPx;sIy*#fIWz@>Yqf_a*< z*-@`ls+Mtn&wC{X{F$qBKRwW#siBV!lTk4Ry#je-#*ea}>If8g$nagFPpLu@G7eZ0 zG8X?RZQ8Pg+D471<>#~PrI?y0rcl2^Ld-z&`b2gy17&-6)8dvTcMe1yEEbTL|WyN0=##0y$`pC$qnSWl1WNkOSPfCDg#Ou zAvr4;WHA^Y3%f!1qTrlbV^EhOh=+i4uJ-4PYB+faZwvv1D8qCw6el|^3!G^C0EJ0F>iG!%$}0pRm$_T2 zR540lgl2;kvWIMj9n|eF#{nr<<7LYmw%W9jD-n!8N9*ki!v`b0uo?>r{! zW^MJQboO>AcKhKZJ6&N2trUO0!y4nXuuTVgu?i$jqL-F4%`%4z42w|f{mQ>L%RVvr zw*^Yq8L42biazI_r2QHGf{y&?2)GBXmlvT*KsiU;58ZT6vQf;_Pqo7LR2n&dh5s1g z_SI@ZzsBCcMVMPSz!U`qo8en7wDnz1V46SVzEZCi$1uoCB!T9ZUUuX2xeQMLPW1yL@4qh{`^iOHpa89=5=Tj2PpXj3N|erch52pj;? zl^l*_%*GC^W45^5i}P>qmtySdj3Ogk|rQNao4vgifbsC#*+&8 z2A(EsU^OqF^Sj;Olu9y^?PU>j-7a=F6T&RMgd;&wN?>&hD=$bT2P{w4lA}kHiyelI z%5ALz#lr83i|#BlmIBn!0q4R&7$eX^SA;4}SH_J5i|UxPii+{(g=luGF82@qOcnOi zwuOo6XwF;EC}`~@r@Tu!Z}Zdz7fk1cwCR&rBfx~D&MRE)VlCPJxC=rd_S@5h9E*W& zCeMYu4_8g9h|c71TZ78}c2u=}Azj+{$Fe#{HYG@ohi)f3n!n`Dqmc3T@nzkmnZu4; z$2B~wT@(*ZtR5pp@=;x<84sOWLPqT%j88u)lJDOtF z+uhb!_-_A334{vB7=-Dg;)eG{!H|Uva->$ebqi7#J5pw&J~eqZD1%_TD}Y)wJl=frA_sp>q)e3KoyVxWatu6PoKC zyR@eG@`n5Dn^T4Mq)K4IOFY}WJ@krsiRT&JM=7!hD%dfY3C*9TdPV%v6jGF{-7+x; z^9^uO!Q5smmk#@{)WlAzuqIrn$~x*Y9zPxAQlv7fl!>)M5i2zr2x)IZ{`iI9@y2`# z&cX+$)!mm&6_E~$>^Cgb?fHJz>gKOU@d6MfnNFgQ|71XmT=;?oD-48o#;p;6*!ug$ zHW;k%l9gOaYzo3@d3I=Mk~{kKnJ<6sd<*Ox6(k}C`Iquk{!1QiO7AJErL1MHpRHKP z3-|ScvqqHC!ld_*ZBQc(s)G09#s@w*%$C3Jukc2^hs!nPPicR+T1AF;TZlLNxRg*eZYg?5oYb&d{zvVOWwH>E+i{pHnIzWnbzSrE!U zxx9=ABU2Eo69KDytB%>Qf6AhC(nIPJ_}EI-4Uj~hn8)!zHb>#bZ8~kYD ziL9odL{vIus}SZ`8y$8{HOv_S_u5i+Ia{v_!PLXx{IyzDo<^PZ56+O^H4WuqKE9*~ zovLsijZ)lvmp*he0ki;Rrr8KT@e#$zTBa!KP3H-Ek5~cJyK%q@EeHtU>?9HTzIK26 zIN>%`s!gO1qcmi&>Dbxxb%433w<*??(#&d^)E8RyTR4~_IseqS_hKY`$zi$JQhb%v z`5NV6sV3yab$)@6UU38~lB1l`sT?*??dV$4G-`2<6<+@%fYs#-99_XjlShijKrlH5 zsfseMIwB5;lMh*kr{zOkV=+Lu^PplgQB$k1qB=K%5u(J;3*4R{2jUpxqF@Hp zCthS{LYw)k5h&izOKvX~p{^i0Xox(AwLNd|oo3r#=~3xrOQ*33d!4m8kpQvG9rDM` zy|Iw6|IU3nc9qj3|Mra8=Z6B7xd79BwdM@c#dEw`g98~!qO)xdp!4$KQ|TBYSH}ew zbyH(t(UbnA!}8scK0~B1xc9}JA0$v8@4_fQA--sPWdbhW-3a?(LDpAfceSCr=H<`G zzvIQLNZuu~IaD-UlcJk1dftyK0_dxGqRsh6Xp)adOg`l@oJ6%LD}_zetw20x<%ii1 z=KLyq*Vos=TXUQlqq9j;Ndq(5%1Jnpk_LgONDprgn#F45cE=NK``}2$aK2P@*TJ-! z?kq{%1qVh?eU~zGxtRXIys{f9T`BBY3*2gu@AgJ>WsO_ZvW5hZluD2J?1 zZCQyFl&R!d%ZAGPAA`US+e8RNLl}r|spg*5y?4B;8!J$hUvMjpF4DR$Z17Tk(`O3a z3uN`8M*HImg}jf_5xA~H3m%|khwuA#j9YEpk^Mv#Ae?V3C_KM8$D<&%BV;=!)16l8 zfcxc-L|DvVU;7sefKA+nr1ctu)>h z)@w+VVUh|mC@vm(=L6!E%srNa2j}M`6QTGV8lI2)$R2DwxNpQ-9xbaVsRdrh*C7i| zb$fgR2C}eFF(Pg|vEkp}rD*T*b=*M)z~K&DTpf1zU-Fvsg?vb2D7LvS%@m++8JZga zPOI~qBW|4HnLBD*wde}IIznFEOh;{tP62mh_lN6`zg!pUN4YlaRJsB-!@lxwL1#S0 zQ4`l+k>E$5wn$D|9^|ev!FMu45Z#A4y3=;A}qf=$(EkvF#N6*dPgcbY+<)*G2a3OQk%!r#N&iF2rwxc05X}yssccxG#f;z+f zzDqRfos)Fd6>RU&fbzgqpWUA0-dp$P?^WhAe2@I>wrANd9&D?wS&yRTb8YU`JHSXKX(tyCTK3I*!`ZPbVX3}eOLn9y9bpz=k$laWrlmeG2x zCN^_EtG|fXmX!TdXV-F0S!tmRK<#3y{5dOH{yq&;oTLH;+1Nj7afo1hzfAlsr|rJ%le(9NF22g<=$~`j^cEa zv8<7d%MrM=#`MavQ89FJ*{v32ULB&O$h}gQBX{RP=m|}tF8f?0+niWVS6s6C zz#3@R!g4UIWHNE~az2Ig7iun=m%in6gH)p5Sck>Ttb?kZH69xTIhIJ8U9>6um>Yc|u~^@T1>4l&Z`zMjfIb*nMd7rFtnwk8cC zkfORph`%~8BY0-F8NUpSY^NzBuR_o`MO{P{59Y>n_ovgYQ7B@9llg3?-j{Lo{%G{B z{^UC<`?2Qdq0zbeQ|L(@4@T8f&+BpP(-$#sGDx+QBT?aXRK%8a(cffdZZclNHopl7 zmW(S_3ht^hk?Tuoyj*KU{qokchmwqw@6NJ6b%8@v zA6)RuorxH)(1FuT3R;JH##k=!_UT5P+PF!{T{^FJ^5Y2E(rfv=+y>FZ{yM#Xb&af`BV=)=!;gDdm|2Oq9DicX z7crqU^v4yZGenks0w4xE?*WE>{@kk|z_XydM#T92p`tH3H-g1@kR2Jm8up~+LV8=`i5_7BJxvW^P~$~8(zy=)^9f#b8>L!w6J`tKXnyG|NS@B`370!;Me41 zpB-?RB?aHwGq5vU$)Q?^ns)=o5yv~XIe&J4?22^(Zd+sD)j-ODw>(t&a_@@+8Iq|u z4SCRk1}xiwR>;%DLE=Z6N?O!Stm4YaI9EIm*v^$OHjl&)#HmD*XK+inU${!oi}1M| z=L`i5?nSQOlTV{vEYpNwdxm^Y|5Q((^@|2|dvr2j*?GA#WJt+51C|F2?>#@5fU5|%y2d|CDDL-H6mFE!taX7niL4(+>`jx2T}=|(`S@S8=C{lCDF2r93OlmlMUlh z;figC;C!WK?Z^G!Xx_YKcItOZZ0b@~(5SMHb>}h)Lh^=L7WHTptCmY0mY%^_=3a>zN8SXq{{GG4PYf~g=*8=PW&Mp>$6=F! zT8}SBB8+G+GH|?^udI!>p=-9jBMBSJ;ea&o7U#?c8)N=f=mMvY<|ybByj5tbkSZ-n zSgqaH$paG&=Su|A;98zC(ldpo^V%B${1>I+eAyKMl?e57p)# zLR~5fXUiz?6dr8CAL58-6?Mv{-mKho+#;R54RZh`J5Uhw(XTt@J_yTTCr;d<`P%m7 zU3z!Vc~zIMnf)#cvo{}A_-!mkyB&(i+`{*QwS^~@}1 znx>Kb`JNIrHvZ4(n$cl+$uEs{s7>dBWor&+Xv^r5Ido$Z@7r^5Aqh32Sa9R@3g23c z3eUVUYcGiHA24I3l}vdRYp`UTwHGo3_Z(lV-fePRh#=GOWK9N#pD7=ydG(?YeXat` z=t{TnaC2l<3N>7>2~4s+8B?9Uz5H&inSf65f!wpDZT5fmeD$DF++LLS zG}CF_RW5Z2KpqFSDR=nwyR%W*CZ_}Ct}~o3ZUyB8z!(jl)PnPLcy!u%2o=bUo5h62pq2Qv2m%9 zxY%Ub<#?RA4cBOP6x6rIKt|;(<_QRNz78UzrkhJ9ah@=ll3v(-g!9StxJUC0j{hLeh<4UVAVdA~IiP5xx?%nOjqptFnf` zgl5E(WoFKizuhnztlrt-dCwul z-Q>CY-9Ri&sXvB}vgM)Wcb25TwM?@?*Q@OtX*}HP^&V!23CP#-ncvo!rt?+*HX{7X zk?@ZjOW@j&)omxD5JDg++)29ku%O<*{T*Je!FUlO`i)`5ozp?)gvE4?`UA6S49V0o z3=^ujJV@|o?Yl`m=Fob^CXUneUTPzcG&~M!Mlkw$mENMdd?`GYj>dnUNrF(};K6P1 zl>o}7EthP4myw5g#hAjSSP2soUpWz)S>E_A``+J+McNN(i{cB9ZT2_+@l4E9SuSU1 z^(KSZrUOs$=~wIKfb~@1_%8O<*SH- z`%%Brc>_IZRlB9fbIE=xd9gTSmcERvo^kpam!*J_RhUwIO0u`mU5$YAs<^{uX|+Ey ztGruVbJ-)6Nbj7ngx^c)vGLQ(v9bCKE4kPb+0gs=K7)8JGsRuU+rjs8`_))cESsmc zryCa0whvK~ZfVH8P8>Bzexa`lcL(*N8k2eMOVvK9S^Ve6`tN59WM^WVl};2){T?}{ zOdU(y@`JQ5qV0QOMf#n37%Y4yfR zQhrEYTcB_6v6!uzxPEd@?0y4-8o`(;-}QW|v>4u6^H{uhmd^XZez~cx5)v3*!j^MQ zuYt84jhB}<{^1)Vz)PHfqs&uZ+;7(N_!7D7R>%F{3=;!afb7vb?KRZjC!f3bFh}BD z$46{2^Y-LCVA*!_Kr`WuZst9=@y~Cvz)XN@8tC-%vUAI-6;Uxf2>1meA+JvdfXcp} z3xge??d8SVFrylo!WQ6b}-Baif2axM@}bKQ6vIR^1{ix9XX=NF97%@ zS!ra|Uu#sX>rpFgz;FQ`S`ZAogM!LRyXTndG&svZbeO9z)#%d~eD-9{`PXXW?uRZ= zE~P}dc6Z0OQ~*AJIN&(xPqJc*x0rp8`1Stw5;MS*pP!#0K9$r3LH(<>i8WWAMtu|@ z;3x`rA?+_l0sDujq{7~OWBlH9rD#)NPy!1tXtUdoDni4;N*1fJ8jfV98P4Clw4}Ae zS1S6r5KvUQO=JdUTEx6dd>f_P(Hh5M{4kCr17?GJ++3iUdw6){2wdBOi4LsScc-Om z2hDCXx!Q{helqE{u6+U3WTK%HH~q0R02NUEv)b!`9d}Wj7zy>;TN@t?1`RP~``Zcn zTbB~GoQkH|dilKA6|T1EysL*>O=vw{iSCdgXAI0}3d!$wD~+3{L#<;i&bKSULtyAy zXNB&n$aJ}W?ga1UT;=acZg~{qEK-`dcf49U_uzc{J?~TW#$c9ATa7}sd6MOV*b_kPs{z8tVdZUn)9HgL_&ng+$fv+TLTt(60a6yp}ByZ5uVAi41%{_i;zu?*mFkcR03dP*!vk<=&0!;&AB!7stWNPe`}mYgV3 zt=>7?9<4B+MrgdgNSVE8sfHTZEYWOG@z<*LM<$AE>?W;{>k6vL`^KxygNebEmr<@# zCDbJn`f7)p#c1%1lL$|2>6mOupTaaSwR$vVc9M|Su}Kdt0P4w><(yrJon1X7w#G{XqCnY zYn+cEuMfEV*HbldE1>xGKt=r2aOd>=oeeMY|M>a}s3_C+eL;{?VnC#0=w4d7rA3ei zNeSuh7G>z}5(K3i>FySk?nXMK>;JIs?)QEB+x?&8@w_u<&M@=7?>x_aU-uOgvR2Nr zF|?6#DNl87&fSZNcszvPdmg`=U3)`wWIu$jwnk$#ca{Df5x>ab<)`Q4MO3me$eaj{ zB`Hb3+2qSC@Jn7Byp&#V)}}3S@|R^tkcfHt%+%L3Q9zPQ;N<9N+!EN0B95tVg!Aw@ zZ_)y~e{7}sxH;cEz-}>{O&rgD3#j~1&QMm_*ssC1wd-a$dkILo+vDAl^Xjj*cVg(U zK~$SRGxLDc$LT>Z@TF2KN^!DW?hGTP$4vVGq?=Bjz&mV|{tP54XmZ=FAWv4B*Aela zRWWLwMc41QqB+fHq@%|HHbS5Of#K>#96& zC?(8sF(AJ{rdz+Nqr7Q!COxU#zoOh%$7*H| zFbV%9@%w$LCQ*S``0OSVPlD$ngiCfKTso5!jKfTnEt?FCAC-%gd#Wk-Vu6{Nv>U~X z!K5FUhrf}x^T>X%7lhN!1A;8(JM!3=^#qxg+;$s%r;6L$%%oElqR;?#wSmDdXH!w^ zgGZZnurQ~th}89vEq2s)w0vROc7|`|_=WWD!L?vYnd76cLh{t+g>zxe8h)hh%@v|* zIUwdeljs5_Ddxo5bu2pdl%8&<5ziiN^%c!}OYck-%K)p@*ZtwkJGOW0vFJjbbyMUW zljEO3y$F@L@|TpKVHYGXH%!%kTTp7+!%ExdFu$eI{ml}YIt zg}~?cYEwTq*PxIATsIljf-{M&Lhfp%+gjsnP4X(s(>PUyRJI=e{6`^DuHEVe%xY-M zMtxpm`-mG6Y(}VWW^1$06&HjOIWKQcBROfRDEDoU5w?z&U1TzA{1~1*S5yz77DK1; zg+Y8iz)Zsa`*Ine){DXl|I}kW=l=Se6P!$=Obk*grOO#AB5p3 zX0o|TC)x6jR~(|xGI|K*5j}YwT&6t<;+IX>jg%sB407#jfYk-4M(1i|P8FT|ki!NM zZL!HK446;-5`y;{F2kfpmRLaAcWJT^JL}SahQOGTCPRr&to-oY;ddj18L?D-Z>>x@Zr-t{rX0b&79iU!dAnnK6-+!3i*R?*4NsR089#V4IH<#)6=|dh8{0t-#aFb(KZW=8LMZoWrjc1HTd&0u2(DggBqBD%} z<2uj@@)a}p4J1{?9Io~eGITb*t}3k`0E4MzgZgKPJd`?jYPIu&1+B-oC%d!2v-q}P z%zyFKSNZ5y#0L_Ko7z+ABCTw;bM@zDxv=$Guj}#EpNZ_e1KKZj%Z(q+-mWEIE@JCd zdaU-UM9((BJbAA$ZFXmPE+?h{u{A91qvmlE^+A;392o4Hu{YlDi(#^K9JM5me%(Wl z)nj3Oyc&mK5G|aUz>nXKj!MER9b^ALjRR0J`1BW=j~7N$hZ4$Wwg-M^vl%c8s9W%T zi(aLxpys{Ot$0TyQ;>gVp*Fku7O-M{9-vo9tPYRUDpv_$TIy*?*ppbNuWW85+J%}Y za}{#rJ~H4)dulsk6yo@`U0sig{Q>FSAk_E1Ae%;Uajuctc+p-j5~-Jg<8mAV399dj zb>+)a5+UklC43f!IQk{RRyQowfHRNur>vto6k%BNzoomX_QugsU_$gmgZ-Xn3bE+W z0=37O;Z;eAUJE}3CfA7JY1Kd{Td8p<0dmij4+SaRf6&RAxFCSp!a9!QrJsY zp`@HC!RhJ6wd{Q3u2WSrxsqN2qIu&kDs|1g1c*YYPrXl@AI!{8z83_tNj6xQ(v8uu&Vd@-SPSPP=#761xA&u}R(EGO3Pp3s6i1Pt z8P;5rgc&+j#dcn>HlRpG@SUP{Qe7w%21roKw7o}nezQ%%iqq(PvW-HF2H6?RR$4W? zXzK}Pxlv#Y-cZRW)<*;Oj~3p03w7(kER8XH-ZtvCyhAOX5ST2&{x*TEM0#}vb=%)@Y7n)mONni?BIB@}&XF);A? zY5QwfbQ__Ur?*V8^r~O+tV_~7mP4tNa#pM6Z@-oTxuCLn_W?FF$|m@ALQ57jUu!=l zS08h${50rW^CC~RuplnJD?B}du+#G>8}ms|o%3#YMw?^>MyI3c{B!JH&}a%Pl^XFmm9T+NtzLLqgQKIj#T2Du$i`_84XN}I z?tq*{YY067blW!;8IieTFgrs!^LvJvJII=i>tWWNujw%&#m_!qT&O8Q5OiohZK+G; z%ca6Kpv@%WaZ_$8-zV0qz`4d+_=s_tPa8z~to4pM9}NaUWD>y*j9@y+p7`T729W0S z&`v&!J;+x%__D*-kHP4fCHG^5AR4QbNJEOu3Q}T7Kcd8HHAhwy^yfhT_Y!M~+c$62 ztQ+vE5mkdRaj?1`PRbtyI4h$Xpo9^fht}VEJrVh2j8iSW))URAjrHgN7Z`^z7Q$i(K2 z8cmK+0hbFJUfgkRz*T-s#1#L-hQ-@kJZa zmybf5JoS}F16FS$LwFT+A?d-f)VcA`Dzlb=iQwH#2|&r&IT+BYC_61J*Kd1vJ|BEO z%8P~nY8Jb0p?X19qJmf>Pk_qMCh;o~*_*a;3d#IRJ)m<8IdksXg%|m#FpsfqxShhr z{Ov7p&fPA)p(dwB5LrLeM=&6N&Sf>YYqZusN&RI8`uKGTH7*1ej4+cfZ63NaxA~#& z3~s4E(c6-B*vxr;Fo8#QKS(N9y)>xH>WtZjr38;zCo|?frpv{T&!Qc?#}!_umBO(8 z_*<&@T-g}LLUL5hi+e);>T&H8#yv^1*(?~~CdAwy#%n<5JF6~*7X2--dx4eGhY}GB z3x^`;!I&(c({_mI>5JGATcX8ucd4$%Xz6sPHa8KpFL%yl=kp2gH_^bK*bYaN<@{ea@hKH>h9W`QSiDgXCP{(5GC6)JqX-nC(Tr1bA- zmm3061`f7_H6-lMH0f$dGL1TqsOqco+TU&;nXJzFU)x$oYGeKV)9^x)^0l@ybB4W{e8Bm6vX53>$V4zzZrx{Fe^R!7y$Cmj3Yn4EqXa=T32Z5<&5oSLND z@w&{J)2V!4x&dxk>i&dINqxvyS76`h-AU1_`Dhh}0T%pUJ}L-?h45(oZ(h>$?7o_= z*aLoxlz`)-%3k%iJO9^1asmeKF@%_ZBq>6EvymWFRS4sqT)_-@#2Jm&`l4 zYVC5$Q?Sg^cqW6$4|8z=P7Kclo(y|UxhTW|YiUrL zIVJ^nR#dwjs435gNi0|fB%{l)dJT*lkakkx9)v@OaHNjd z^m>6PN3!J$C>ZX*L=ZLABzxz()w_bC>YONOSq8v2<*}kyEV6OT(O_ZdNg!>koCY81 zA5J8hnGd_iM0`+dw5~AU@aqnqa>i7Ll z@PD(3(4;xp9_@1KC|adNH(QUW3KzU+HpLTEYIw#3{DjKKS}#h?Dhcc^Pl(Tfr*VZ9 zu$WiMwxfDP>I@J=Hq!;42z+q}wG8di+F+a*(k)k~{4Z$^p%dtun3@XIse$(A;iw=` z{gin4J-#%h78ccCj>f~p99Ky3d<+w!!U|zeuIRUz#N+LVcz-o5(c`{d9 z+Ba9Exc8XM!z|KElu=E>g1MKP#5G>a?~|}U3P6qG&mrc+WuZ>j3vazfMhaNem??}& zc<3j~1XxT4uNY?CpOVHo zm^z)N(PFEFQqEV|yU9mop2iU_{a^-jCgQ;(z6x*Ix^TRMN-LquZM2rY8XU$5U5s!d z!OZFL%`tl`RcBN4P+(kO`=%WI&T0`9^}p3nKG#sGefQz4i_}{RKyE)>TKwMmxrB8~ zvsz9$+_9Wyg}fU*K#;15Q7)@C{6}YCZU<_;KF}+0#sO!Wmw@S<4CdYjbT2;md_j7E zkoOe0HLHG9TZvjvbAPT%5E)a7Z&@%CNbRdG{oRifnzKRb{S?QQNDTR>vRzYzEEt$N~f5K6x&W zNI06U-jlkd@)l2bVNHs>P|08L_0K`}#m-!ss@&@LsFYtAl|Nix6I!j?>*@yb*n-`E z8u2w7Dtu5M&fK&L$tnVP7N;$ikfZ+L&u)ov#$YQm??A-);pK@gGXreP-}8Knd16#*{3NSDi#pk50nzb%PR4 zsZ3def^lv{zeLVryr?iJXpA4|AEE%IMk8P*N|*!SjwZvfpwi_VkDnW?({?NcDq@k& zPl&7&wx83!2_Za;{P2 zfz3S`$^E032uNOPnZ`5>OQlp!Zc1-@g%tU%=I8dP8rN-g!j4Y1rembDuz;9=8S z&|A0qlcV?FOcUR(xMuZe?|Vi+H}))^7?|J_}u&dIZ(O%5jwxrRf?mHBs zqXC55)w*caD!70stx=wtE!&6Y^4iOz`CRdEZwVP`!UwSAOiLKb|JwZYir^ zZK$CDuSfy#ptff1pOZy#uV5!w802M=o<0CMBryttH@_+QEnxRVy%~wC zu{SYe^e5oVdjoyasT{IFtp9h5PheDV_Ix4HByCvs=fS~n{}7m4*wwstxz1>&#{kxJ z7n`^bq%5bNvaw21_2ad-j}>ZeYPY?A_gwvO^+`HmUpp>6t&>+(a3|UKL2~O7yDP^5 zdie=>))Ej!e?pLY$+Kq-#CA3fg`$JzmB36}DZ0MZajM2g`Za1u0h!EL(aUkKEpxRf@bP5R zt>ocKv_f7rOjk{Bb0*vT6!yHu>mN32%wiO>a=Xe+&R04}=uZu(pkj8Z<3uQmwZIz) zQBa*`cdBNf{tKG6yZ#G=MaFtI%gxv4w*9A0+$)C7^S9h`Wn(DVG?T{@KYk8QAFd_K zSD?rFk|q}nOnO25O7#`8m-|>0h{eGCAlC)$)(2DL z`CW-M6153t(D@6Ec4yB0;#Ix?|Hh4YYI(D1{~9&xu43rMvR7Ku&D63|Rr_nhRtBK1 z3Q4Zijgc=aJhqYjhhpa-@Lp8S(yI1gx)2MH-A013@dlbDD08UZB6-sbxH-f-{1|!T ztiI~FHTIHOw{eM||GQuT0fRCvOFXmg7#9)6b7(JML2ZqlGl5~?3Aw)jEMB!#w1QkZ zNCo6>>bK@*m!Qjs1^)YY=3-RB#z%Y+r1>s0;$(` z{sxX{*5A(_t>^OZ4kfaWZ_o_M{=pocTJMd`CqNrnzNZ=`T!C_ylc8H+-QrjYU! zD^{PC>j8$oLvJGc?K6|7UH|cU`KuLgHWd5diWZ<%eu+rI$F74pxI254W`h9#I<8=H zXHiPe&knc`aroC0@d%)KZ;Urc7TaL=L)+|>Q4zlL7B>`$fsCJ(zZckVENOpO3>rT2dS;SopeflcO^c<(EJyGg zmA~~0T1`kzEx%hrg|m)EOT9?6OF@_A^};l5iq6qH{b(v@GWQ&KH7TQ=zIUSl z(2$x8tc6xpUF6IlZE62eCCt#=+v_swQ|kuy)_ZDpz>}Qp0e~>HUj_xS`b511;D&g* z4^RSSPy@dl$3rcr)+xtHw|q$&g(KIza_XPo*K(Qjt{VzRwFHV=pgZR}nuTe*k)L_< zoBsOS^GMcL^`p_|?bU7*8*l5lnFW24ZC4lXV0bDw!Q*031t zsAdWYGTwPOVDuONM801soy~{=LAToGAhOnmIS!mcE6i&903oUNP`(zGKky;p6%ZeLk4Up#08#W4f#%hl~YnG@w=DIf2Wz*XpyJE7Lm z;X!E?T%(8^|3$T-oN`|dm6JqpVTEV}5 zdw(9ZbHWdO_K-+Z(2y$=)abX+jU+P+MCKgYv--e%+$*ok=MVwHU=QTFG=Vcdg5h3EIC5pB9K4tG zMhfk%nhAfX*h}V({4et!_J(GI)loo049SjE7nTn61#)!{K zd4QTpB|454)@xhw+c$uV{g*x0eyzIu;i?rnzN`(2- zOarxLF_*?H%mX`_73HzTzPRaqIJ3NtJd(5&i{6opiZo8zMYfqDMl&+Oav-bd^y zPS5(ZBGIKU1aG@|e2>OinV&!Hbs(8{uL=d_6hY>X#R76o&5VZ7j+2CH4R97vf>Pf8 z&lKf9=q}kDv?x+3=TU7nP!jFXouF_1 z(LP^T1oLI&-==2&eJ?aaBj&1E#gg2II|zmql3A!H3a7E9y-sMMwZTEQM-#OpITj+F zuplY50pLIN=l%Y-gK&1ZJ_0NyjoN#C1j9oU6mzNv`{GL-LF;*?QXGkDGZmsl$xlMg z$SU8s6A6Z`g`TiB&Ian?0Eqo>Z}iv0aBR5K37ClA1gtS=LDVm`R=~=?-5dp@+MqP6 zLeg`~O(l{SRrjk!yUDimZo z?D)yWrekZEZDJIuHF(sWZAwvRnVJ zullzGTiCt!HV-f2QOK~93D-!x_5P#i>~kM1tlQF#wtSRZ+njfCL3m}4yokOG*PFn> z!Br^#PMG<>H{b8ixJV()lye|S`*cdw(~PSK^T)l;upNCoi%}{5MKy5%Jmeq< z(0slru`X3lME}c;2c$$LGfDa{kQIj^5YNyEmkxC(i9)S#{Kf!e_{18$RLX-f+;D-K z;MC;r%8CD69au5~85CM7;})&gfkimnF7bT=24|;Gl>2x|k%Cf`qCRQC`iLYGcPcJ` zUzJ_%3CVENIGD0{zEAP}mybpE09lwrye9yG;Tf8L0&B=%svJ~mlY>MOje~x$~`6y>n}A{Z!ska=(=xb_&wOrp#TVF{+uin{^Mk! z4fQ|g<9|rYzGq_1FXh^SxOTJGgQ~^LP;Z?=5_>4%HEnz-74%EoA~}b6Y=r~T2!8m- z_BNYfHM*Oz@R z0d|xQ@phvYf?@r@vnnEa-XYjK!A^SHrme?>qnNGasQwbOyj=WY4=^4>#hyO%TBZPY zZ>8%u4};r34QM{|N+tR?R`q^2W{J3XyM+I4EOSXj(JTb3m|&|-?;|FOEbqKJl%P-i zD7|(oNoS)tE|6L4OBV-e1G%DCIn(2h{B8FM)$^25e$3{7Ug=L1E7ED829z4cHp!6h zN7wZ(zQ6!RLbJ+MHhkV}{28yam!#m?zM)3r)ssT4FBYXB<3jOrr6*bo%*{p9JWn)g zfSUQ_++kxK!(u1pFT=|JUIl#+NNC_OvB)2o**z|=EWjeR#ge#M`K0~4L_tE_#+Ngb zio}3sqD#s+2bpfdk@xlG{RP9)u&J>wneR&;$(dX#a$y(1U8?uYR;U-aqpim4s!D?# z!z{6Ys_&i`0^@scCXY_{3lj{F!cB}PdkoD zeifu*Jah3J1^wEY_mg;YdS3z_al?Da#dqVVCnbq9EzVYU%l&Au22^pQ(;`bJq%)l+ z?FxTSiyaVJtR~S6uw8=S_IynN?>UP%!w7X@GO0Ngu;P1BgWNM3d!x4eqB| zUu6+1t(Q9=Ir3RfJ(P~7t^@%X%4D?YL>%H0EYepL|)D;8`S=^8yAr#~y#c)!KaUk>n zN~ZLOQf2WkiHm@zjUfoyM}gB6U>P=A%`>lTE*>IqIq%&WRN5HBsG-eeb@l3*hk(rN zhvCkD+p885eKaYOs$tJA%b6gB)i4_q!6b(wd^|=HCalr-kNluPSeUK_L`x4b8dwxP zkFdPGZzr%Hhm#0UgGdHc=gMx#4)fEwYS))t+0v<+VF<*rAU;64)IfrYj7%6916XP& znn*W!c>-GK`zS!FDRbI!yX05NQ;xqI1&wcw#d6P?S^$|`*R0X#CZ`kW?*FZN@1AL1 zetr)M?Y(9kxPjmheTaiOzb(c)ut_J9=9QEXH`Fxo7nORA9xO(Lv^VFu#+`UtMJS#1OcpSVXQFr(0*sZu^#P-3j#4xU35j;@e_9QFyJCE( zpl(5-E(#&A1k-f0_mWZl!{Q9g3Ex-H{h+;`vf_Qv;khyatHa?-$S60q!mz2;Gvx}w zdy1*F#$DvNQ0-Kcfh6jAugkY!W6}U992EfWDj@I&X5`ZruoF}q?omop+Ap;0n&N)jb^Mg}+ZN=fD0gIgt|h|9-r_H(g{dT@rzK7`p3Mjl zwQk0qq;;(H&r#x=axL6fD|0{JE;ZfAWp$R?pCJ&au+EEF_Md+Jme!=;H8t70Ou0X& zUS<|(VIQQwQ@U)Z4Gg7FrK5Rc&#`z#vS<+=Jm>)eGB7}xjwI+B{9{GHe1by8m1z!s zRR%J_JYd1f}ue5jL8qZ5Fz0*bXyQ%+EY-GU0-(7D^r+!oNlWs#nahNz{ zy0q@Y##nz_fG`=oA2bdH3t~t|-+3jECi~*ymc?0V!C*L7i@Fe0kX*Yc7HXxtg!zo`4aR2@Tx2df_1_V$FxbCya^E( z_{!Z!Ih?mxnoq9dv8+G4o?{^opH^*}yWv3xH1sxtA!CA3b-%a9!Y&Ace3!ZjyF+uG zTHwDajXko6h5hl2ne?8uMTifYy>h{0rqlI+x{EW{lOgKFy^P;x(i>h?uHI}HrXFW9 z_bZmFR)7~r{IT^fLExrQhhLk#|A34pj`P~&Sq&*R7B1{^z&yMy8$YzW?j9#6%| zTn}-EH6Psyk^SCulJLTLA@Tpz!u)~wR13;mfO$PY)cI%>2K!pgOld<|C6My^6N#=& zwOUo2XKM6&yu7)wZc$Yu;yUX>iMnyEE(>SO5|?W0Wk`CK!ZYU1ib^L7c3?_dX|59P z9;lh}q{s80mJQ%iMTFvS6zHN*)JtXPimb5_frX{WOf~V5Ik$zH3#$DRi(+^irqGSs zl_SZwIeC+CG^`-6JWm35x}Kv$(Dl!|6i~0-gBo1iJ3{rCq$9~}OPy4(nyV6*;}x5Y zXCcHo@Uop~&($-f&mc^44)EESaO5%OJNFTGkdcz>*pO@a*VaCgTu+Pq!qNZ4q)YsJ ze}BsjHOUfZwD>B^V+;%`E@&oQiQG>eWeEqfwNmVd<+3FDM>dv^ZoBmll4v<5_5;e0 z{?4TNW8D{SM!xasCA>!>N>%nab&IY<)g%$M)2cQ z490q_5AFN6%?MJ2fV0IXdC{zpKlH zw&hORZqHdA*EKm3dpD&mi>0)^IuqRW`1$t9DmN*>I>a+^|E)3b-N5vrGI&sx0E25m z$HLm}kB3}Q1w@O8KXj^Q}An0mhCY!6l$xUqcefFZ0@Wi z?VpR~hj1`h8aunzZvXgdCKd)cK8X~UJfehr%eJ8z$EaO5F0oBN6A%CPkVr+Eg@=y> zq(wLVf1B=`p*f9aHI+u&tH}wxkg(9*UCf)2j1fI3om3zk6!PGKli!CAnf}O8eq>Z1 z$jD}AXE~Fw@<9Z{-dtOQv-9+;@x4it)B5$oqqM!6^Awu@KmyIrn#;tMS+N$!3d-4J zVIxTIwaDFezSg$f>w_rnWR8I#~dH84NyvO zUzAq(h4$G{-X*@}s%+`Ms6NKs6D6CD9aK{J$jx{+g5A4OdYVv?|PLj9=H_6t)K&Z1w)8Ro-dNW2zG2!0JS^a&hHwnb^z*wc{}>-Q%%_iRXec zZ%y6PD2LW1XLO1;HHw}Oe*2^mKCOHw5fp~c^%Z{m;Miz<&=MV0V*O}yzA3%n#`@h` z(cv15(^(3j)!>Uql?ZvSv>DoRe>NG`zzF}U&LQrmxMB0diwc4^w6p!a@#1js`k!}s zAP%lY(JKSVPIpL}~xlxH29>I4>oqRa7W7&S6a->a>&(=t9&(0Zj%XXq> z3#rPzE!50F=Hp8)e6{h`(-b$B9)c=)v|cjgbSV94D7?DiCPq}zRWetc2_P(lQg^_l z6cfUf;8Dewh1PTb_wP5krPuAZCNkct6z?f7jbT%%0_ADGS!gon7KTQhRfDo}(C6LR z^l|5gt1G)Z_2G}DH;O@xFB;r9XX*0RjX{}33AiDuO}f<2qX2nMcKtbV`~19axp$$i zuB&@@U72Xj_1;tiU#l+`!3QT77oX;4zFGm$QN?9Kx=2}<_;_p&vy=h5-R*QY`w}!P zqIAvmF_%_0{yZ9sNzDwNIuKfS@~Rl4B?Y0-Xd)i_Zu?ZeO*emxjk&`7D}JZ?EfjS^ zI^TqPTxhEQqhIwSUD&+>NMHWbz=`)Q^oqN(HWS2?d+dr@)H)|hMFU^4^NP<&s5f4n z#a;SjPDDuNxp=i7cMuX0$;oz9i>|nUG4ywdd%f*g6pgXA%OqFA0eRn$}Wz1fsgC3>JoFkf1 zyISgrt$Zli80i??WVv&CC9eAW^D};%Mb7IQNBMis-l6b#0?$Hk6rZQ@W*1L}B$gM5 zWXnhppN?#H``y1gB}5PGeo<`6I3pev^x*7DNO>7bDD&<X5}YZkOvxz_Jo;!e4W3x2(WKt771BvWUen9nbfq^>570!-Kmi0Ma3OY zw5r9dG^tGdU2B}dPRu5EiwmJ=Sam!l=HroP#yM?*W&yW z?x(vFJfwugDDB6U3r`8}rexq>6gC_!7Wv~C2`J;19g^Bh+(41qT@T*lS&3Qe*}bD+ z`M?}AeGjC?IHHk}qh_bd<&&J4pg)3VF()cq%VjaohOJ)z7-^Cp);FK;%JAi5h3@x% zQ;-3Hn?DnPKl6|N7S<%PQ0Fl13{!V>iU+#24nH`k)K}+f4DDZWO+;i&(w3n zt}UNYk&{1+gVJMru8hySQl2hfXp)KiUP$_`h&8WuGAnbQ&oTk z?l&6|gTTnpzHc$0Pml5dK}J00Efpq7KV+d&o|)cx?cT9B-K^^9eQpi;6XOjLi_4j) zxAH7;xI8)WA_Wo+Q!$b7v+0J9BJTg%J(q%g1`&wU&hDd1P^Lb0KhgG(A98I)N#Q$f z_QXYbsi$vFiop?GT-?!{C{%z0mLjXlq2xW&Z1pS~{U6t&3Ia5|#7d||6p~>wz?3yh zuLYi$OV2wt_bghW2~%BMDU-%#ZCfM%CCJ$>ETh9iWI5R}j7)N}qM*Ty9~WIIFZ)$B zk!2tYN5WRgLTa;+Ycq*7JBAWeRpxjbyS ziQdJ#Eum`dRyG@9(?CT!t}SONvu-%p)?k^?ya6JBc83jEe%;0{)IJy)Ud1Popz7F6 z41EWE97G`LC(!(8@->dv(!toBBF&RgsADpR5gk8r%`vVGieIBD)E~80;AVx7CJTcC}Z7A*004=|;;nHow4yp7^N$2p3(|l}KA?;h)ujH2v6`~P^AbH(nlN-{x-!-e5!KZETUU%xPGsdG ze)WK0)h-rnz`Ug)aWeS#=AfAv{sqxR?h5@4eN9m5v-YQ{dWd5%9j({Jr`1Stu*F)j zWX^=01K%Cs-?78Id29fxC>Ohf^9)_|;i>H@H;HRg4fijC8cvg%=}YFaRFr9CQz~B*b1O3&&C`@&P$rW-${7~YM6&y$bY(vWpFhx8 z;x?d}TdbWNsq!Swaftsq#G;B!*GW+tLpISK44I+ zg1ZZg`*`B1?j%q4sTgs*yadn-z(U{r3jPHY;F(;q;_C75=&7LJVW(}e0s3Vu*H0hQ z*an+@(`-f3D#Q$Gg@*0~Jw6wAnM(kW7AbQYX&(L9l0KwzL0a-xJ5fS|Ndz%y+A5QC z7K>M($*WL6UmCmT6r=_ZB5c(T&e&w$p{^3IHP|kH8m#pMX5aaZ`fo3$j&UCY_ujG@ z>q7KJM0GB7;)_U!-R~k@-5j=r!ydqPWU|^ex?8@)Dhabwe z2lvLFmno5nGT2HpKrFlY9av&MmN0&MHE9L2MT7ScY~Oa(+ajKpB>wbS=ekoL1RIC! z5D@_>NW^;-ga?8);c{(wE}N7Q1}J31k{N!8R1FGlEzZ7ZPXJr7Ms3pZYvqT-Jj~*}U2y5!VvbpT->vaQEyqs~n<=Ej{;@NECs47tt_`ecJPO2hXgM}!dMW{Q2sf4{tDaY9dspX+;}p2 zjux^wCI@amscVGR_P#!}xS$ikO%(enU=i_EWDd)M2%lVc=Q7AYhZ1?Y>lWJ9gpY5H zRQR&)OZ~(pVj_=y9H>F5=ho!=1J{OtBgva}enW7P&`rD`HV>{-m#WCFTM6WCB=sk{C*WDV4r@8xn z{&<=7x(8A_{V`k+3V4h$7sp!~ZJ=v+oY!w_#EGf0nj`~5h?vK>Hy^{f?K_ow(gf=C z>g>C4HOgLRa9AEDaFQT8j|q8BS-V}mKh9^$HUmJwvDLdU03bx(Z zzJ7Lz7CopaVug%{zI%Sn5ca(qDKoP7<(zMo@Xmb*Q_BJ-_nHR*PA0&UsDS@KoRG)$ zFe_|XJ;Q^?SBV+;!<4xl6ZAxozpek8AHcaC*flLxq|IYAns@8)3e=w!BYU7*E%WSp z4%pIVO1woeKqS&L(sUc?;d;~y!iOwEot>|r&UXaIZyvcVhl(hb8X(p<{D_iE~fo$)%qF20HJD+^cH^*|Byl;4Pc^y2S>ud?V;C0%}T;Do1(-ncn-)RrTFjTn3 zvVxP4=t|cIwDd+TL6Dcu#&wz5)~CnbC3+SkS?_=?!l$eKHoTrB&S2G_HFY;22k*^D z#k=~0wdq5Ts9mzjD9&bCoNvZMft(p%TStZ~!M;@%vsv@av*eNHDfgahv zGGU5B{AaWljK8HQ;k#p_XahB&e58gME$x)S-UcG@tUD~`9){70EL2me{z%X1N4pJ*r-qF(~_h75u{!fMj4{`_@9e)8t7H>Ra=Te2)RXoUk{z7W>r)Z zF3Yf02e4Tgv0T#!+N4jQA&IAI2oJyOi^eUd8^x^AaA?U!^HfGxH^#iL&QN5@tL@jB zQ%fE!Q$&Nfk-pm)1KX|>Cco+!(3O4*8tJ|${}+T1tP7AZpMh%)y2k>LUA*Cb?IxX9 zvxWmnl_M1V`hC}zBAUa-?^}~>_8L8n)qvcufDP4oZ)W(eUa`vwSS4ElKSK25g!%Dt zwegmB__)C(FZzM8i4z?O=LJ&0O$o$75}1Ey*GW`)nc5jbyWxRpn+87HihSY2LIBwD z!G|ZeSK# zATgTzQg*D!Gopgh`{rtCbv3)|!^^g3k18Q|k>AkDLVaMLWQWhO?@0X7wv`3jQ7n!z zv15p(>gsqLlD~{H>>?#8mI&i(|OA2)OPMcaz6G_Rl^0ixgQ_ z4ZKPU1C_Sl){tL>kqCTx9Csh-L=FFq_y-4%qk$G)zJcOsYOQO@&{)>rrw7y z+9(*3s)xk-OJw>yLUWFEGPzb92>)`2IDtl}uS8$B*K8GcZtL9>BVZHcM!I9uMRGGB z1sD?#oPG~3`^RM~0*1g}k?}#Y;Uct+S;r&dj1o%?^GAk9Ot!&rri6pB$z{TOLhgb{ z5Z;EuM%LzeaTbW}XVE(>z1E*dm2YPa>W`GZA}y}sctDGg4vw?b{?b*!R(4#+hcQzJ zc|m7^&-vl>LPJoOW$w%T)YN<)9Ijek)_$XHQa+&;b3;b4^& zO-R^UzN-wNG(O|j+VA$@Y^k~rC?gyYmTpM&(6Z0SD5OSGgQq1Mopx%0!`$f3&9LM4 z_tLjtkW)B*aEnlz{;i_%aYv{h$&S8MrmsZ`Zjd5N#A@E+E^0mXsfGY>O^O%Z)*GYb zozl-ksjP&c1K+$i<#7kBp`4N)LniY50GarcMhgzI?BI3g<{$-84@WdvV|7i_bn+;G zl|FFPr1!vpF^GiG_-2t~>h8gj&@+^oyzY^VTKn#z{J+bGZ;WVj7fnO^>^u7G6sB=$ zc)Ij^I>j!to$=r_H%i%&K%|J^56($2Gp!h@HBZ*tTiQPU_h$gdl(=vh7z%egF9H(c zuo-LVhoh&Im}u}*Y|=IST>eF$#5u79H5^PThEd;T#P!|hhsi@DZ( z=NnH@atr7@>&O2r`NdcPm zdno{U_=mUH;^bIiS^s+^9^wr*L5s0*o8V2)@qaA9?_~hqry}25$v&iBSD{A;~_zmP+PQ>K?~CMKpvGZ2fonfMEA z7;+AVWW!%oI8Ta}h&rO}?Ckh+exm3ceIoehN4dXxO+-tpxI0@XW2lE)yT+~b`xZrr zK481q&2yc3aD4EFfq>wXjb^3Zy#mWB5B}e@&+q@Um;;m|<%=7Zg(rVXm-_;reyNOW z7c~<-oIvazLtEXtg7}Y152=Lvz8bqb@O~@q$n?)#jrXC`=VfMYvnAq<2re7V9sK3W z$N!Di77K-55ETCF@DsC#UIO3p+8Ie!T)37mtG*Y4@ego5{}5_Qq~6fs`rx0N3VJ_F zw>Z*g>l3Bx?EhSgzuf|{GU%2vK8o#&DwA<#825S#knPTM$|iB&0`EFewYtv@`8rL_ zxkVtA=e~TWkHd2*TI0&M=1w|Y>8x(1*+eXHciS1qT5_&dk=4KT@F{!3eb43j`(^68Gvk&e6Avuze(%k z6Pp^b&=zI#IR&yAc;pEkGcIItJgHp*ojU^>3B&CvJx%w#?XRX}TP z7Hm4tti{7pQiE}Ue-;AEZKYbc$HtRsiJSZE>bA<%BY-xp7Kf8V(Jb!pnbveV}E9x_~lEf>k ze?eG-cpC;B1d)&q5Yghh^#tjk(f1XffAP0+hq{32d3|4QLHqx_5oEykv1q>bswMcU zwL904*SogZ(H~zMqoDQ}*L^g(>@DweP{L{+?zi4qnFSR$nT(rV07={x;MpDF9Y_#a zdxA@CyMtG&W4+~Oqi_J<_U%*vy?(RRqGQf{NuJdQ5p17lbK z37#SzdkgTM*2(?!`Di;2fyajChw=9*>-cP+16KMj_~GBrLn6R7AqjtR^McvvmL4Sc zFGn5l0IdAu94*k*5tmc{j(aAyETS$~`(gA$-T*o>De4o*=?<4fWG)NPy5!5S-jKi4 zlp-|y_oM#;6>hk;2wG-D??Jv=nY6L(vnNl&03J2=!(^3tQ=yL_P_S{V1tE~i<2CJY zrc~4=b#t%V!=g7^V+F_TDq~;A400_1v@ubw7I?re?d%jRVGdZ!N>7~y7CxWleES~0 zf4dJyAtO*-r!#2Bu+3K&6&sr}Px%H*_ma>Ro9@2b<{YY23xo!1k;3|A{~Wgelj!{C zuS6I3iE7U~St<;)IeC??3P|5vUX6LP!Zog z^P}TOstu<;ZH9VEHlPE3dCR1mumVYz@pfe%vaI^N3jr89_mMXbQZY*F;6jm;js4lq z52?TKEY)Gss{Sa*LE&uyWxa;;pqj7u6d7K>?lg;cq_%fY-M;C6O(COc(@iNn0EJ++;;*N{%;r9*Q~ zeEvSU3pd1$T86-Nw%+CDaV)E|k%3mhC1``)x45DwUHjz5#`2aU9l;p~97Wd-CyjM7 zWAUgUHoNIb)9ov*!MqjNowkduNOR#gu+OM_Z*dNISk_5F()0)vm(1-Ow9Ctm{0W%f zoBzs=N?!avOm7d{mg(AQN@y|k9iuvf34?cj*x_T%a?)#A>M;7he)$4NvYtLHJe2wQMqI)#h&j+v)VH^YpW?KPbqRx91u}{jeA^X`{_IR)s=gz2-dO z(F}dG?0f;mJGU6wg^{m_i&;RTUuvdr-(PYKjvt5xhw}+QE!ETqQac}W@@@BeH2B9=pNIIE=)N1XT zN;H^YSUiQH>0D8oYJ738v5bl)6S@|l5C>5v+L*mJ; zj0*BSU{;H$g}9e8I3>QKwgWMeXdqwFWO#t8;iQ_sbuxZMWzrPRq_;xJ)Ak(iNn_}I z5Nfp(7K5_p9Km6s_1)*jDFc3xb-ywO|_k{3P^pzXL9zpDP!)YYxP zY9hGKa^hGKY*|BBn5TQ=Tk;KPglKpu=PJr2DfNM_f9AA#ZksZQ7|}eAXB#*7Lt_OQ zUSb^=7C^JNP2_*Fe%8kT0*&ejkdWu;&u$s9P>($M5k z@nMRN7nl)_73nZojc^tlkp}Y)sp7Kc7Znj+_9b3rz4x=m!zmKQ&_8dVt$VOP4S zraSEyW4CGTKqMFg1D3tXB@j3kX*S;J22`+8Qak3Zn*lR{QR<%{1w%mJnYroc>0no^h?-GklMN;@-o5=iUQw>Yskf%!c(jH+#SeniH!?KV1D z*FL-}riz9NRE7rNgKy0-xRfg9g@P789q-4T{xL%V3=< zvj)q(MkBsJrNXpo;GG}Ok8S}7gzSc&f-=ILxS)%|2(UH=yI+WL0JB-Ot+~l)tp-|{d zi4a#m`WR@ zi^P00>c<_k|MI0_!7+I8@F!{T1Y3Jf07R_ss*5yG{}s~Qy5pHkvwFCi2Jn79Im^eE z!t@!?s^!MYJLy~udH!3~?k(ufkk|5Q@T=aJ;?z*G0f=@tEIzHI;fe^L`#i0yWt~SNj#`zb3q*lH}(g>zwiutMn8b1WOf6~T})-2E_ zuJ>l5+xV_l1N2>##H)xH8QzQP2zo8?O6m+l>!A{T*o+Zz3LR&jnK=FS9;M4Eo{P3+ z&Q!!wsn#AVV2(UB_0)=9^E}$=arIi7uVPebi7Qtev86>4VV;f8;PUQo>Xx}o9w5b8LLeBM~A`l51mo%RA?WQF%)O@L}V@IM^v=>SG4^6lgGS>cUu5!{!a9o3NT?)#Y%sCK#> zZeYbhK;;*;*xDF@+Cx1EN9)|YpOxIXk-G{+E{1y!nb^rDtp7Y{B>O}D4XYL7+7*{1 zMzmpZ?aDw@FoSlTYilul)c6X(rLA!=Pu9HOMi)Z5*+@k?PLUaL*$lcgE<4b8r|%{7 z!nxz%8}Tm!8{hlfge)K%D)G&|%E3#TrKLZ2(yDZ-8O4llSxVdfuyYV;LdBg zd)+$jJ^NbD#m%f<;i5TPUeVqWRIdLVkN4udRkeUG$Y<5!cj+iaB-sQ@g9bE1Oy%#p zHSXzT4_5td_!{>`U%*5Pt}nIwM+u_bEIf1C9C_cJM~gv>$M$h-+NY%ejs_3_*CQkt zZhIqXj7=28@q9PxOwP<2g1OGRlmk{sWT~5XSeV=+anr%5=W>@j|M2$8JW%J8VP?ei2k!>d88zxvb>5uGLp& z)@T@1nqtXVE-L~x-gnzQmZ&`=9^xg?0#LZusvcMQD3lo?sqEAbO;kAwRs2*ORgRkO z)G{O0I%-Gy9wKyQL;DG+W_m=+QKs=RX&_;rdw=d`UACo05WG5%wW#n;vk1raPiK>z zX^uk)lOV_R;3xKHyiOveyDaO4NBs$<-j%cu?smt#<-Td+llR^BD^Oyj zruXq3oJg$uym4EdI+@?cEbM+XVJ3OoUaE-)YuTteX#Ord*UCYk0d|QsVGEHr#C*dSCcB(blJSlu)M)MRYIQ z-*ixRs@Vgvhs;3LhY3I^egeIj&jrOT0Sxl7X*o2%3 zV-6hC84&?+r*Kw9_h13TEcLOla>uOR-ILR+;zQp> zN49~p$Y`Arr#{b9z6X+81{&834^_mFF!&&9rAGZ}dB_NZ_Wkdwt>vf`dn0WKjCSyB z7p&GHYBrf}w3D~?*ZqZab4|AMy7Tr~zJBX)9FA-GRtlasDOUo|Z0v$;c1PsYVwA_D z^52yQe|&dJaebI){q8QGXpH#QoXT*8kh(BS?s*y^_Oz9E%To`1{*-dhS0-bx%@YX$ zhNiric{8=X42#?RKz{)LYmkbf)dY><;NF~8d`3FXmzZw=$}ay2D{s@a*rS3(k9jJ& zZUaJh-^8vaaZ{~?dBQ&T@{90uy>k-jPX->4NvBPbz!Je%Hx}Qhza2uq3=?qn)+J;! z-k&=j;M&`Gadc~Tnbv>%_!+10sCT@cY#OnXh&5;$IpwH*^WGg(Z=br6Xpj-y$MJxu zkzz!Qe`$!pla-w-P$Yml_4ovb0c9ezex`W^F|%rYX7%k}(C+H}^B%dxA|cU=FWAkA zoIF{1x;UKrm+1H=*yF1;1Br@=bA*XLaF@gZK57b{&lWM{5;Y9;B^z-3j{Z-%)s1F{~;x7m$NIREw zPKCr6be<+oc@zNlpu@ytj9~^U7VmTKXbHg5z3^-WDW`1ibux z5f?7bmY4+#L!5lRw*^0G#V%1I6S#~OUf@dbUp315Ch=f3SpIMcc=KvGKcIXMO|9nh zj!%YBzfZaUS^WjV%6Z8$XLFCQdE;kn5A3h!K6=HLk>VaEPpho1-5$c)u6Q`?@&ldS zZ1nLgQbhNqAKc?dlrY~K7GUG$q^HK~G&)qTmsw!CLwV}zgzzujPgHbVztIKKDCKAC zS+NX7CHmTIB!qP664~DGzVAT~Py=x;&gL+h1%QGnI;Gm0J|_-i#TtW9YnCzUo|PHt&uDmds5KC!0MH z^Dj0I8LCk-Ta4b)J4v&c9{aLHJ1-8)@P>h;^(S>3YoXTkDNjeEjDaEL=j6RH1x&hx ztU4m)Z*tUYz0oS5K`TdbBIxuMZsuEO5IzInHM_`OJgn_N|FO-Spzg)=icVx2N6`t+ z*S+`q{O1l67VEozo_)qA!3QE*@?RuHwA$`t4iH#LNi1~X*$*3q#c_Ok&ffHMj{wyzq|}a7jAW_RSkdm!;(KswnhD!D;_WDg}ULF0T(<&II?O z%O&Wj`moML-DBH!e77iQE99xm=esEC%;8WLH+>$Rlv%z&dclDz; zphsmrIeKuA>nA3#}>?(uQyddgf|29i<1Z zcX?F$t^}kok-e{i5Sle*U*Bd%_+luDVH$5_q7LG$rd!2Nind^UlWmhK$duBEc~Mmr zJ+khPGIQ-g6wt}%$}mVkB9iLs;2B`AK`yg3`HkyyMcdj;pXy81T7%Ha=P+25zNy<@ zSTKcZ&KU+7zvmxqgb(XGb9KZjkq6s z-?Wu8Lya4%(xu&|Bx<0db7TwQU19_5TReAuV>am*hOj*Dj4-M1t8kbOvN5k;0KcRm zsTeezP-5B2W_(N=UjZMsnJ#Fv@epM?4Y53By5|vZLj%9Qzmdy`_+t4~&GD0&EHsPx z*ej9YzC7w|Y73L`f~aqODuusiveq5v~wMVYnh9&;u+ zVxD^&h|BV0Ia$SzOOwK&Jt*h<4JO~aIpwW(q_3ECaT_+NyAhUkev+p<!G#@F)7ahOD@28yqj+u4Opq`>T|yIl zkBxe}jHQV}foghRqlcGcw^!G7mhM#6xDh*4r*PyMn2M1Erbv#C*CN(=%lcWHKAaMh&DKrMg1tG%wEj zPEsnB@XUTgv1)(jO*wkS>KG#BBAScSI#}iN7c~f%JgKOm0{a}iMtu+{wvSfVpbIWO z>+n?RFqi4DlhDeBkFxl({;-t3&f#T`1Z4wm4n)io(>8?^w=N~kE_ z79ByRbQwA2mCX{v=37MHxi)jmf8u8KQoI-3y?6e@xuXQ~^POf}TW1a=MwhH6OTX$-c?S2F zhS!TvGe~~X9>Ulj8hgzQ>UUaaE+U`kLn&KY7@zL%k4(Cq`OtXw;lw&$8Q^GCO)=re zvku!%mbz95W&^S$wT1yj!AMO;>Ek-fGZy_~VI~YpWa6#|rfq#q_GSGadq%jDkvdwX z&`Yp@80cb)^F#p4^bg92h~h)Hi8BEY)V38NZ<7SP2-6n>-!}H@>DQeNrfvhH9*bLFBsn(x}aL3<+For}-7A>0~*{bxIP}Sb3KepN*B9E^yH8|4$l-e^j z+y&6^KTTJPiv=E=wjI}hYVAPb+a{9zx#U3Hu{2Em>0v}7=q?XE(PR$J`)FX|Hhuc5 zSE1J?*!Rr^8UmarWON4@OG{2W1p>MbmMJ#vl->dvD+>+K3#pBw*{Lg)yr(*bq82xO zI+|Xgs+|YGFtr<NSGW+u$7y%hckp-vJhC`{m3r*=Dn+d^2yoOo~C<`umzg zj0}s=rNd)p)-9Rg2?P|=ro;WK}$&(^b z2NP;{y~0PfP^&8zt#GlO+MjaQ8{xq5=h9ESdP{I!>|MtSwj7Zm^8QH$RhC}e)cGZW z#r}8gUKXr_6N}*=3eGQZ{rP6zf zR679)>@KfeUHPAjv?+Q5j5QYMh&XDQ9i4}Ju$Ock?tVey4Ld+{`NZD1`*Z1GPgC4o z-cDM(vdwXqz{gqln6Excb`c%aE@ZsMJrM;dk(OvL0*3MHtao07Q%H@hHjM^W5%B1Q zobF6_7Z){+*!ZHHCnkE4>(QS6oRg|L`8g=DOJ(*Va|yy192v}aAz2j*+xmT5|Gp$Q zw4d*2q!z>(i?d)3mb9?St>ffH_c=HHe&Y2#e))hRsnq+PJcCJJY@ZCs+$!_w)@L(s zKYbW8cYTU-H>BDS_o$iN@q8bJGyiMOpH4l$;^EMQE0UX`OCHe7@F%(-$PtmF`39N< zajl!=_s?wL={PUQJMO4oQ;Q~!KlT^bSK64t40pah|0+J{U1R30Vcub&J0aM~6ac%+Q>{d}}{+_BF>U z$w3s07zXfW%i@83k{#n)%?LADIg80_7|SX89Er_2KoEVW2@)3=$a!b&;16q@1>+R_ z5-KCV!P8rWz`MPX5Iv`u_q9BmZM!P(($U5|1`Tv!=bAT(SuM_|MtB_D!|xvm*_8Di z&5T@3O>T>#dbV#WOB2kEYZO}>uKgK!!)tA}vR537rfiL4e~mnd#-LlumFg+bD%#?R zVE;>uw#}V?p0|iSpd%3X$b5cZZx8Eu*ELT?WAwD}hb;Fkn*WB`KD(oJuy&GOwOSVPu$?$8 zhTjO1^sP#kl7Q1%H9FJ|#-o;8!W8~3CWBhq*1l|2;WrktutXdI_RyHF_2lV%LPn$O z^O7h*`dW?@Cr>4c+t9bhnbO{BjFtYMAC?tAiM+!GXw?k()R15f>wR1Kr(R0KNB zJXt8t1-65Y$G-onjzjpj;lKNfQ(+L!@c7**KL20tDU77DqU3cR{09oB#_vQY z{T;kStj}v@*%mtW@(IrGA;jkI^EfHdDJX?ir6SpLYsJ$r&w)HVm-M_iW z>VJy;wD`CTi|m-mkC7~hISI;UYXqg*PCv10Mia<)@oWP)!(hiuMKa-Ts|A-%lgpPv z@715>Ih7Mpr#E^UbFOcj2%hBi5xQP$Qw%pxT^-v`#-WzPE<2cY`8r=u=&CiD1rAUK z{$K|xNyU0v>ahC1mF%=%t+$|xZg7L zOfNn?|0fiXKOf$DZ>}Gg>w;}O>Qj?sIo1A;!D7|IWYSpPgTDW2Ft<6vG3C~mbpCK4i+Cb=t99TIkd~Ksw8)bJTli32k z>XU%vqh$W1nYcL+NXWYo@gqH_+CK&46Vf7wB6iL#p*nvG5_`q` z;rNl4sp&v}K4DZ8$TPM0kWw^RqB5&*fnhZLY~5La>DNC9C11veNCMoNMy^4O?9agL z;3f1D&DOWr;NR!%eap6u6gfISSQRL85QN-IX=qj#Z%>pATcUgxF19<+>v_tms=-nA*MRGB#Acku`(EvQV4!QWu^YQRWq6| zCp3`6g=gr!pB)5Yz3&){?LaRb@;sPK)~NdMI79DSu|F2!%&It;dEZN}ABYlhT*vq} zl`TsCnUe$jVB2aZwAg_o|CB@H{{KJ-vD~YjkoM2c!dVM6Udl;qI*9L^{$u|8cen(R zF1)9)tMtSdi+?wP2LJ{@o}qZEAm`O8--~m={jWa_&@&?VW28sYBK!#89k8YUMPvK^ z`a{&=?8L;x;Pj~F|35!Y?7K6Rwd?0(49EWp-hW_s_jM*N1;BudNwmMZeYLRh58&h9 z5vhPpZkmeD!-K6b=xnt8zrx|4CFO^DpWx=><74N|cx8p_io1XOuV5H(Wnv>sk=T>T zb)TK30U9Jj1rrzdg%1*b)aqSf2%z$qR1`Iw=F+N{t3Jc$G#G8Klm+7v`ASoqSRkNa znziun&`TnBIOrDWC_nL>cL;^psXPQUkCAYRHiomJfBcYjmVM4nODn1E_|c74?Xn6W zj(cbrnkZAndvOTG+p!!@LV&l4Yz}ttc!)2-qP1=T|Z5!p=^jU6kMF&Pae*1U8sY z$-scaxk8Cvr7%AL?ob$`-#Ua!0ak9Z!7=z=S0Gd7sz0t3 zoxpKh4u?Z81{jS9c)Pq+puT0bPx~CK-RR`ir&BDuIaWZb@ACYk_x~y8|7X3BVIE>A zFn&sRIQdX&eD>M-B?AM4d#pip;j9*cQ&&ipbzl1nDUB{LiM2^qYm$kk2Y{M&9Vha8 zd>@eB?D1!4Ai1xpc8U%1Rf-B*oj_wEWbm6v4=A6~`f>RjjDl{VzbSAmx}$U*`E)Gr#sclSED03i2jiaM~p zNTz&%vFF`4M0b{D8GV-@t79JtDADCgoGzM%GNnuajkq9RB`RqC?xo1H=j_>6hu|zG zI)K~nlwB%W3;jR#K9LKY=Y~Pw*Qo4OfY&_)UdH7ax)5y!V3GXDMzTGsK>am4p@Cnc z<5{85O<+9RiOaxqj^k?NNGeW%PJQ3CRn)Zh!{*0Al|dKR-ka~M58NG(>-p|Nkh~k$ zfyB|jKF<751JK;oUVsW3n80f#LdXv0P$-xWY*)dUd+T^4v&6pa#T2!PUOolyV09!W( zulg9#PD;JXj2t*@4y{EF8~2Tfw@v~Fx8A7!le-wgCKYFObt_^c(sY1Lya&?F2j5Dk zeD4D&sn2yu99Fp_04n^-d`=O}VL(_U>HH_PXP*Jw?c+@~%&$?LbwGZD~U*x?U)7dU0kM@Utxfh8Z?dQ!IjDF7U9w3$z zY3}Vx2bi;&TZQx0#Iv7jpp|9LAEcZzh0&*q%$XX?uRd8>nzh#5N>&jz zvet8TwtUL^-x6X1$wEZnpdT}tIN;Wda7+E#DDH}4-^bY$5(fO{A1!$XfB6&tiQWKB z2}fXu(^PIYDv3&WWo7yXpU;K6Cl%y!X{N!s2gmOaNk$TTihg~9qK ziRF#tcgku&3`}XNroi&&IMRpO@_>4_CHWM}Usjb;J~aln@CD>g?#>9ez>Johc#bHg zE_Z<5Q2&|=qc!X^J~!vzE)K*!M)!!c1#w=!7fUY9$U+AIvHU67m-){!@#B)XL8P6$ zUsMo1E|sTdLbjCGfg|`kvY6;Sd^#D7isc0($~cX8I<0T2*k42oi{uBqTn9LD<$C@( zAm2!Dcyj;Y{Cdc;wz5p;7*YYGD+?+a{tc!s!+- zhsip2<>LfPkIS1;)t%uA`TXT9IRb)pRl}c_R7Z#BlvgF8zpLKQ(DGp(H1;4{#5WH5 z>nRg^3e8Oh(B~GenfJ@V-$S;ru}rhUWQ4LuE7uubU1=LRQ)ek+mN=6kGcr6aND zuN|YDgrNS~HHf^ssBR|as_(?gXNRc_^}24jP_CMxxoPzdb9(XC;cn5?XS7_)w=6yp z*bWdz?FiwCaE>JE*9}t)Jz{6WwsOR(->Fj{r;c+u)mQ!Bb^u}n(5Ach?AsBSiEbR! zaS@PZVnbhlsEE9l(vCp1_mjw$$DCuYe-k84KqkQU=y-9lQu934c6Eg?S-e@f$YiUn zfpkhk;%H!vA#z@W8s{h?UjT1+eSw2||7qS7}P z6$u)w%HtW3P#y4eYcgTwy&%}AC*METuBkmD?4GX=mgi>;yHbH{x=i+rMw7@ik>VUh z$#Rargr8v2sb?~Zl=PzM9wV->PTtfp9a=sMcG2EjZnl`e84a&}YJ;tCJeeAnBWBnz zP!}!J9jXJ{Rj@ogXd3vbQQ0fWPJsy{mq=qimzekr%0`DfV)LY7C41&|WrK>0N1;yn zrrLdK=Ds4ma6f(m%=$1OQr%l))%P}GrU+~Nx1a0%JhADXQq;MZp71M5>Io7VS47EQ z*DzEF?jCyctyIbf7&6TBQ#W$xa5pgj%;6rvRQ@bH)yQDWkQm}`l1bw=$+7iPR;&f) za;P@e@@j;jTRF2(@j3Un_y@fXeO}6qftxFT0))J-GS}eHcu`gjs~T#}sh1kN30&#% z#k>LVwa!X=SbNtsD<4OssD$=5ZM-OjGhG{k;u^uxs~&Ok{mF?4Kbw)*o7QOM&2V)+Cuoh((An6 zYWujy781h6cKq{NN+(J%7%2?=88(a{s?IF9VP|mo)`ZVF$f*`_OGI@o_CPp7Nq??m zw!P@IOnuP!sVod7tPF&Z{(@kG>S6p^&LP`rKYj1`=es;~3$=8EDiq7l2k$auHPWt{ z9exyj=K9!PwdKz`HgWjK=tFP2l|<(|@|;m*Dc!qldduY>k7p6aybP}-N0i-RJL-{j z4YVs<%rl_5InE?F&LEK5n~`f>`JHb{Qy&HqDW?|EGVqThcv_RArWYB>trT4(HH71M zcg<3-PmT{U6KH6uh+`u?DqB(}<Wr#MIYR-Fpy3ldZF$$flvW}ihVQRefds@7LK z#Nl-O>Xnf>Lb={GsEVUA(8sfyN5ugq-_=$-%wH8J8MOP$#B8r!2z+yt6?nH=_`#T0 zQjg%X!cGxlUw7l^TS^#>Ci>#JvS^7d^B84nJ??zmS(NG%!LRGI z(|m5FhnZFta}f2)!nvgLWoDmKv@+f(Kl46}^48LlV}CxXDicwOsNVRsCzm)it~(wX zvae@PobS4Cwy@e%Y7pzfXvs~e&o*TxQhj-gPwN_811kkz>A%hKwF|f(+E$A#CvXqt z1iN92Wwu0~%w{9H2t(hAELc7pH*y49*}~pin39&J!KqBbVu7Vwp{b0((q+`(DwX!W zaON+4QEUs*wqK2G3}evYy|7vGR4q7(hF?Ce-sN#<|G*9VpL!CI3im_yU(>y*LnKH9 z&*Bbhmu7h`?**4J(rY(l>!VV?64jQ}IbO_U=hj*O&K+AN&GtIZO1%!J4E)V+Q_V4T zSi!>EsKOqheZsjvZwI#6l$~T11$SW*}*Dn^~21R8^t-kD>1F!;>*Q0{R z#f7VVlJ9Ii45;LUI`zs`D7hqCesQ71yrD0Tcd}GqKs(G}#Flh*tU$wovZv^Di!lB@ zyZ3)U^I-{YObYN4hr$*Q!Nz(Y&i}TWX;8EVg$p5n5(+Y#%~vSyjb34gChP!)CDs`oyZeH9p};eVJ+zA5r_>r!ATAdL*_cw+LwDt5=OnS-_zTtHJ|gx&{I#zfnO?GA`-okJz4V{J z5HbZnVf{5J=jN6DeQ&8d`g?<$0xBkIx<27l-4cmC0=i~Z&;%2qsO@8pyjP<)QhaR+ zp0A_a$Z20GR<-&;KFSq#Ah#*Mvb_0@tJOEZLatyqtx`fnJdRX2tYJ$K0!^;pD6gzi zjYeu@un_C4oaijjsjVnpLn8CEBscVi(Bx2K zQ{MktA((LNH0YlOPJ7zRzq_sfhXxEfO+V2N4v{s8D-tjvK-+-G5QV+$T?D!*Ge7lH zwe)2|l8djd4bo6&lVz@VD`Va1y)nYn4Hf+K*%MAU2oV^rhVVWy?gO80`V3@^bZ@sY z@bWxgegS5-fupG#%k>R5PIZMX>dx0s5XPRe85@L)EQK>M1dqo?mPHkka^$fNcWamt zOs;TCMOsz`hKM&=aJaZSJj18X^0%2~r$Sj781CF3_V${+OC zY{Emvy`(U4CxZtM7>(wu5?4E#PdrTfg{Pgf^RMD4g_o6A#c%TEpVSg+FHOxUQ8V`1 zQ%&SHRbHd5);bvs5#d|O7SYYf59OnE%YAm96rnGO&&eBF?<*=(OV-(qdG)}r5d_xF zfFCd#jhW-XYyr##*0@-A@DuxBFauCy7o#=^_upudF) zw1;FL4zg96K26*+Nr!8zK|$n>E=crp|8spIdbg;DA{2_s8{QcX#@*xtUzHc=6By0kB)-QvfYqn5`0@6NVV(#z4T{*W zG(sL2A1!o~&Ycdkte2>|Yeb zs8y7=e0kYHgy`!JzjVn%RJkr|_>-x|`9tvYq_+}r%X;t4lpMUfnVw;_UIR{0GIOYs8^j8}yg!}lv4S{% zznq_q@Gz3F3F_NL~o8zwEZ#6^?YL%f`^k=(#eaO@_S-!Q@WLcINpl0X&UX6bx-j2?Lt z%ZQ;c5xH!6-$&pt+|&6sn_s5l^g7qIagYS6>L+Q_v+~n$iHCeMJttRE46r>a)%m|L zK49JpIJlAM)X!}JtlcPNYV29jm!D?OYKLJpLlB?MJsemXu(Q2nhnSqs;%rG1GADCs z*sfV+AqA~U^Vwcjm+fX>Ry!ivUe$0aYj)Je1>5|JkrRK3a_D_oKe+F{5fwu3e>2`= zgNUz@C|P$H%OnS&W@|R5m?G9V(oAj0P_hveNlt#-!P3D;COlezFubRMOjFplkC!L> zmhN?fwmvNKGapbuVdajbv0^?rJ`C;IyCcoIy;h%Xb3ksw+8t1n7!B+HLd_-HJAZ6Q z^`kzR2pUe0GWlxV7+-Xn9MainoKBZN&Ud?X#WMr_0|@u^(>~y6DwnSc{O_T z1R8rh+u@#)r$tey3a-liYAN$7d@&mBM4hZ{bzbwk+J0TsaAKR_#A& zNe^7K_$6xQuD=t|V-?HM=KIOAef^WM#_=M(GTr|JZ3ln%*H3B^Jo(GC^L0+*1GE>- zafHr#B!dN5Rs!$6Z5AI>1`%3s`6a}hl;q-NF7uGSU?vk?eZfgz@dnvPmQ^&Xp6Wj5 zojwSm*H`8l*l*U%T64(>KU`!-a&FMtr7=e`RyqBUTsX>STh*Y^8Ans<@v)(m%+aeO zjw>{dGTJLyRjx0<6oyVI`% z3!VQvP~O;ulq*U;9QTtTyKjj{Lz^W|5Z@=EU8v56SEyA$;}T9SuV`0&&2qpq+f(9X zG|_d-;3YgG-}hsD8W>2B1&(f zT4v_dZph}p@vL6DS6I5N8t}}GV7VO92^_v6Git2`)p15ds+Toke#H`=6}f1J=<4a$ zM9WY8)>=CCN9$pE4m4Vg<+_!0+rCqE&JINhH;w_&vyHhB=Gu~&l;Lq2*0`8KUN=5fA#D@Ck7xcDtq>5F=fmNC%MuZW;h#p zlquZet+Ljh4(rSG-w5Wq)T{g1T(^~y(H>m0P4$>{{_s|wS@bB-j)tPU6yL>_VJF*X zXFtVoM(ejH7Jbo359-Lx50oiS%D1yYJ*0Gr1{xs(#YRcBY;80za{Pq=}yqk7z{n4MPH!o9CAv;6^KfCN&Xr!Tzahs|AG zragacIC8-f7VpE>GZ%{gv5W@BH1aGu=LEAP2ov{tx|!uIX%<#M$am%|8|Q_bhF^sk z8tL0!D1V5Wq1smi#t;Qp7b+$1Tbdd|7x5#$CMORA8U+<#-(c@1Drcj6&QC;~>W%8K zho3I7*8t~dtr!R8A&-6_DqKwD3nl-WKSM(9p|At&#*pJ4dHDk>PqMp<0ize;hHR6b zvZw=#liLxFdo%XNwv#t&5dou|Om#&WQkl5sT}K&8w1>j_$YjKub37FqBm(IikScV- zR?-Hk>vq=qF|%!T!87SS#s==9{41~ZQV~n7maF5QS{*9kBqkC#ydz;9l>v;Mkq9?b zS*U}rZr*&IS$P>&N>Jkb@RSe9Q=q{=*pWcDRPUp=7`L|Y1SE9{EWMMR=GNIp7l9+C zXEccPd_ssj8z-D}rzeG#VwA@y?EE3B~RNJ%e6z^xca&Th%wAb&8(Z zT}AkIt6Zh=j#07kMdn&WX=y4^f~9mUKHRMyH%_D&x&jBR5XlSExe)0m-)g3?+DGe4 zF-g#o-9zkyNin7SFU7!P4dZYvMs*ayGG4M!w=4s%5>y_lqRH{1B|>*yjrLU_WU(hM z$Kd2vNIXKH?=)opov%ky>`|=loCqUOQ9n$K)K@_QexI91_}c_d&(D_=o2T2SMwX#?#VqsJvff0%wl3vzTgeS2{H`U$QK zrp%%{+>uyx|GTyQ$w;ff4?)jhM+3!)p23k7ga(G$P*Re@3~-A#Z_&3kXLG)8p+FW% zO-)tLCSTNf$0PK6g=K#&xBE}H^Q-wRz-}A@9qu%f%f2)Hq?kQ_)3KK-OZ$rx>Z)Qd zQ@dhEq3DxClzc5j;LHx6x06w%R^Uf1>b}GFd@t(F-8BaF66w*kX@84Q77sI4$jP-< zC)XyuQZ#W82b0QG()lV+PbWb%Tvo@Ub!Yog8IloQGXXaG! zTy%}alf*}3zX1>3Zl}D28iH)5t3B!0#q8Iu_EgW5Y}v%y$#0{VMfCp>_LX6EWy`jK zKyVEj+}+*XU4jG)?(QC(;O_2(;O@cQAvha%cjv9{KHcY>d*Aoof7pAkHRr5ZRb$i` z+6~9xGU}s$zN`vdk!4;@@bO*_)8)*ue$6M-dOUxFJk?WX9a@|`1KGW;V=rezzmAgs0Pn z|A|0A$&%~gte<=)uUI-e&voyFsB}ZaQYdzx@^E-pV4bohgFIe@J^4$;DPc`m(wmY~_~6TvNR5rA;Ga;N$3q0B;2U*|oC+GkQJ;uEp z*3ocC;j*2RUPuKEMF>zCj;RCmwLS`@lG^|y+Qh}6`nZPkV?-V=GCGUenW*?wa@j^R z-PDll^4X;wtKhG>_?kr)L){@B>pG@3vPkFbWWD0+9lcmZ>%gxdVk+(Y;qr#wQa_cC$3hKb5q5 zd4u8LDsZpHW5<}4>BnKr(Q5s^!4b_^WTjq)&u>|U-__s25#j+-w;IZ z!cW-=suD$;W0WE;$g@08x(E1(C?*te9LVyzzq*I{SZcGnG@CD>{)zvYCSDgX2`v%5*sSw? z^V4wu5QW7Q2524q$|ZQ+O=@lpZ0Ru0TBL7)qrCRp z!q{i`s~`jsd3FlPil$t)*Lsd^(uotLS;;-cN(AFN*&lGy!=ieFkO6J4nK))u3pvc@ z!4Z*hoYd0YbY`7x;)v2_2U)k*t|#Om)nT72f`^bjr$8*r`qpBX;d+t%yYhA#G6}M< za*Y#aOntEI73*NBluK_Df9F72oIcx#+j;hhT&qY>{O8c(ilvt;DCpdYHL|1XyQ64r zGEb7dMk_2TS{%b%e~#Xvy=;XR`UpM8|pp ziTP}PFzD}UwR(-G;Wmq@l$bi5_bqQ?JfM4z%uSm$c7;Atdybr~SU@R77*GZ;0EXhl zQPK5$U)byuGq~q&^+5&pB#+;EQfG>8iU7US=^t~JX1~5687sQ+M16W-d$~M*dHd(h3Nc+drJMOaOcXb>D)iRVgI^^rH|sy;I-(0?g(sR(E^b+S>Lf1TfB% z`*@uIjodUAQ+l}!*2NXu&hwjjJ;nMTpN)?J8%<@<-Q!fLN^v1}|D*l18?|m*3Tg35 z`;)-oT&wL*2<4}(!xmJxSm*-j#IP3EBVo;2li0i0TRa}~*XJPr9Pt~A>uH%n^J`ZW z*WAo%p-2DA9byVB0u~qEVu>nu2h|^{v7gvW7|!+~oI34blxwdnknyXXpn z3kmr-?N8$0s*Q>RV!$T$Ok6ygiw+<7WN-)PLU6;N-T9rV9mX3?xhnT|7K%-L1`Slr zQ8rF-*GNCRM@O6pihZC>$Osvl-pc`m$dJQY3kF}53(WHwM=}GB3N0922nE*WZe|YR z$S^^D##@D#H41aLn}DG7c}b5w(PLM}u8LEfDN|cKFJGH)I%0&GIHc!`Y$~Ty^7&U0 zvdyh{&q3x_Liqc`e$9l~z1VsmhD)ADh6j5ydna#^fcFRY3TKLI;~?eRq8IC}Y7HOj zD{U%K1N>i${+U&$hh7`rSZ*(@;g)hR`V+6TD?)^{liqRe1uRzrrW+NUaBc|Oet49~ z@g3T5!MM~X$Ob-Nw`o+*GfN##d-oB-VCNB6ko58eMNJ( zje>4LcHYPhqh8_qvfvYM@H1Vzcfge=ybu>Wv$%ase|J1qj@C+j{kGvDZ}o<*bBL>8 zR4WRuIU)9FcepvPGVPBGmCy%S(g@80#&h%P<>{<2FeCWka;NYkh1CN!ATmY*zH*69 zQ);!ye0|agvctO8`O>z{Fr3Xmz-B$20t`M20;<*d*9X%V$=nPctr8qdz^H@u>aZ(L zPot1v;8?~54LY@I456f;sM$&h(F^Yh1+`L5gu4$qb%H6=1;$I?6_t7eZTp=rLCZu| z)J*XN>oOLcfLxX&Al+POz1$EkxcIFv6ofgRe~C^#14x(OyLC$5-%J;d?9+q#Y+jbi zSj{()u6MOoBZ#gwfh~mzeeFK);(GUV_Rv0l0LAT)2Htje!GCvsX@tT^Y(@HG!(n$& z$aFj_|1*tx`d9=o^HO6}G_TbuJ(5&OxI!pM9oV?$5#U%N&$@i*|98p}@J={f@A}*z zpzrFwEniPm3278*b7C#GoHkHD=yN;kQxtfzkM2+#4-wVYq_hgr7KKrKNq1WR*)3<;><+HH+b%hhm((` zgBb}|BA4}m0y8+2nEut zhTTgY5&hV7q!oq&@>i#>XOj(gkGhZjJ{a=@BlL0ZRdu`5??R8KUpx7-B@)cm${^Uu`|Ik|b zk^Gf1k62X54XH>n*8g&MNTf`yJT@G@GIuDxcv3;7DD?sT?qpSLPIES(#B5=nM0~pP zxE$KE{l>E2#2uf%fuP7V&qE)4%urI9G4W+k2-7=|e(U*Uk^2)9W^mefqoF_D zx~tdWcybmiWHX70xw^V8H5<|#iLFf^v$;S&8Qky9<{FwVRq5w@t7|e0px6Pjj$yjC zGrAe0xayPd1Z` z8`Ngr?;oF4$Y1(#GD#DRR*)K6)fPJ4hCr-llY-RRCCJ93DY3k~yrN!pSvvsbwY)4g zX}%I9cVo^^-X#fH)@=`fBEcV|BWuMD}X@8gM#Ov#R4Avh znrrk<^qtjb5|3_(xZY!;JZMJd7fQxvoT(-j9^x{4U+v00M1MHI{EBszu_N-y9Ntcy z3@1eU$q|~>hM!a>DZ-_y*byLFnk`gKh#oK3x-!gdT*IAbtn|3n{S0n3fNp+G=A*U+ zb;#Wu{??z^d%B+2li3L#q^V>r2KW^!(4Go)wji}>zCrCKvpLVR_K}Aw`JoFy4WOGt z(_uzkFS-t~a^#;H?Hg)nBLtx~@tr|Z3m++F;f*M=GJNH)CRSDB@hOKY`0rc90(pO2 zLNEcp9-S?rJ7F%K#`wv;DO8^dwv}qK&dO`v_LGq(Pl$?*WtufL(y91y`|SGiiV2|uh3U)$g((t z^f_lF!TtM}dMhG#3x%ADA3u#ceKByUD+9yA)zUyiC7*-a z6-}ba5!X0m;<2k=w7 zR$qZld3Fflr&bm5RwhHAoC6jUDJ+(0qLnQth{1f=tG%6s441Adzy&_c&F!im!7v^= zOygoN9RYCl*)>IOOJ8knZznS)x}H<3oE)_6$eZ#~A(gm2-h7R8F`q8LP>{%<7>aMu zuF;U#XR*n`a)}R`%*i3VP-AU4sQo*E0m2F@I2c;8kP!NIL0gCO#zz#iE|`sVjyqZv zAts_WvuoUhZ+L!TiPSUe(IF|F6F*yHzaZUCts@;QOo`OFPS`xU%yO8-17!E%Gl}Ol zf52}V24AlvD9!Ct<0F5?4YXa>jceay}*u54nS z{pynal_v&z1?V5l)J)0rpOu5_)pUXzk;nre5)0klW~l^Voz$a!uXYidt3oA0D2!y} zJ04!2ya^7ns6R`my&kc8JnV#B1BC7pyA9zwmy<_{rQ8&FYS|t~*iCW3(||MB7Z%ee zRTyBp6DlCaKi?#m0`z37A3%?a%}i0Dn})4;lZ4Va05vr3TX8W35d)DZ|2DBGN+J*u zmAd>P$9!^gr+meUbin)ll5_R%G~s2(KlA9%SLh@oTNn~h|LUQ)JDpqj;wj%4!{tbx zcD>(N9_yFpVtOoql!m(yc+O}ZF?Z0B0qo-O-BR;@PxZ1+7~ z0U15L!vH5EZjg39oVR^vDU?{aFtR5i5dU)iEwE=0?AGdebEIE=cJt`kf_3mw$C;JNYNs80)E3cI1-mFjat1zlmOvTkiAQ0 zFzsp5Z0{J`pX^7|?cog~rbY_=Sd=5Qk&lmG;1}VdT=t$Gpg*t~q1Cv=je_j#vsNa$qdA*dd@w56%xn!GB z_n6uT58w<8>_}|;bOTD>3EVY4WTaO!5@ldLe9}MS9O&n!*$-bNo^9)-Zs-s)$XCIULijnixHUzw!GfI zPu#kAa5!`dyvtkE!2Byu`ghkISeRc#eo5uJ`GB&UB-S2f34!Ha@#BEIEsTmvrE&{&%NfZLWbBRH`=OZVQfQvC z4GwBt1LdL!JLd1RM7rfETr06Q5k{G!Ovr1$!=0DTS1=#A)%Twzw~6C4D|E?Ic$Zt zovKH$q`9hdpP!p7Japn%>1?^{=hCq!-}vzkh67mJ!Y-f_;2gHMXB*hxjqs^VDJ?Uu?Ydq;9rQ%q|xrIey|h%{UpDaV9&@ zLlFckK;p-xCAjYDLL&Zd`Sx6!an5+$9RxRf$2agm!8rcSk3jlV)bq~eI%IwmwSqai zRTba(tTXB#3-;VJ)t>+Nkp?H0Iq_h!|dJA{Yhyiu!+O)_%ae z&oFYK(rP#+naf>8U4G0#fgKSnrq(WKa$`5Z32P#Smm!6e;VS;~ph~YVeiVfDYdZh! zNJ{z1G~`YDr+;2GU&90z(p%TUM5|i)r`ci!hQr@CbTF+IBj}qawr=YmLSVzH$tqv| z=eoMZ!f3a;$PtSZVO40ghN)F(h|?r7ppy<-nu!+q+`Zh@l-q(_&991Xv}audLL=-@ zHSeDY5K*!??S&hVK3x;#xOLXTJ^(X5g76U$u4wTob^jI0GCKIVNPZxW;<>_%t zWriWQ5EAi#{OxxN!{R8sg4cmj>`rCCji{Y`Z2ZCwEH>o0bf2<{0Y58J{*N95*?9)? z{Z_P?6QIyc-A8|SKVdbV0Ww>@#hVy9cKY`2^J5G-v3Wu`p za>>$qG+qQO5!fLr0gpG75>dEu6R&_f88G3OWPV#9PmHf&I?kgEs z<}+Wjbu962ZOG^-Yj+a_S}@-_o@^o(z3;0S%waL{gU|Pei01Wt9_qKXp990+^?-we z8>kBO;@&rFyCL>uXUoWQQ~iNx3DQFUVwqawVZ!J!!~9Q(>rX`S7o5U+yn+A;@6X^b zSkT@sj*j^mT#l072uD7K51FnWLk57(E?FZ{Z~OjFshQnP>(Cb5;Ci3-a$FkM3T>y& zZ7IXrhc=I~Ps5oM0`qV~;=rg`oc0?-B9#K*9JS}9mla*`JDtrFaRWt#vUBe{6#DW0(Y>*Ssp=8zV1!bahOT&+_N?Zu<&>9PA0qsGCiAfI42L8mid=cdsbt z&q?;Q68?GhL>6Eg2wAZ(6R6+ki-Q3NLYgwuf`6yDe?6fJ>3SA7Nf5+?Kp)9r?%&_a zD+8`Co|_~(;eg>`^}k<>3Q5eMa_Y>Y&=k4-v{Cf2OCbYy^YnNkd$jDYt`F+{_JxrF zxz6!_r(F~fs7h|R7!*&=|Cx#X^+#4+uQg2URyIC!{oVW-)(LDk0A_w*a~<~yV< zRYrBA?Cw#Us!qRzTk5>Pb?%>lp#WK_=4BrtbD7`yqBc@gw5)+P1lke<#n%M@sx`e= zSm`?1KGRC|-IQ87wUHK5n@j4c!;J9Bq2ZCBkY*+@u4q^=w@b*$d&Ji1_Ksn+<3nB> zo`llUGy-LHD+OPZcVd*>ss8v!>lL?SuguQEga;Y`ZpVbw6_8Q?En5gcSlA-SyBoe6 z$aoLAwas)YH}VMqdv@#T7&S;d4t2P){0xmmjYdOM_iJ)3%}=)6EN3jWNEq6gt_AWf zdH;IS0HIwVMv7HDYxkbAKmja@=aw^)g6z(RdYqbFqq zXQyMzi;x57;p4;mpIZ$T!_Tw$?W2OwnL+(d@4EATDie|?{0Eg9_~y|VNYg z1i%2S(k4adDR?fw7LLF8MUOpi5(+G#O!8HxFohJwT)r>R#r($W z??d<(t3gnhY~9tV*6oStnB%iwa_p~|afXLrhdM0MfO}{8KaFe36oq})Z+qSHV_hLhN z>b7LrL6dH#yGV7jK67noEAf5&?-h^v1*2_RjY!mMF2j~HEA}Hid2?q@h0=58HRG;!n6y?2J)Pgd(GCyqjHxM|7;Bk!ib6ywkd{tP>VxK-2un;d>f`fAudo?COPR- zfs`C%99h26ck65!vFI$XOQ?C0zg7_7rHsP_Rzn9#4EBUv$ApJ{eGqE^x`d$vdSAwK zQ;5~6v_e@}gi0KLfG28jUdO#mM+uS1orpAl0SYSG{mcxenDv8DV(LDA4N+Z9IPvey z>%&xw(f;&z3tCfa{x22Fwhk@$+n!mv~1FLN$cpB<|?>cBd#&P<%&^pEN4xkuDbt{7S zQ6wvr?_g4lRAZEfs+(1mb1zF#2&MinX!{%&C5GYT`$rXJS62tuyPRGdn~AC8tXEaV z@`u-lfP@xBbc{J=N2SD0V^p_(ZeibQP|0BgICT}xkfps!@~?Z#?_T^DowlME@~1~(Puj;*4N)$xVxr^z ziq6qJ?eY-mAfscB*Lf?{}vMsnA)QDvh( z=@qWVxYwj!k9Zz}%z>(8u4a5m)Sw*}34S&-( zSiZ?LNRP!f_In}VN1d}q3~OKb2gYF8?TD)O40bf7xz<5VfB0*K&xKJY&-XA79gc>m z{r1AYQBb&i{&&a3KweO+FNI|W|DQx$P#XoBL{#pa!J~ZyTV2u__rGKBe-R{Ns$drC zM4dlMFQ{uy$p5dD^xuEmOXSBWJsKPG=@mCb(wXxA{gHM=)iKBjfaVG2|K~%=z-CmK z=%MDS|Bgz3N7BD@q(6UvWD^9cRBjTfs6Nac?#<|b{Ovg!bb-Q|NU5^BN{vxg!Dshu z@N){cP+$Zc79O5Zpi^u6$nyjSUn=D9(*k^+;4|5`Gg(?{i<`ZBPZ;Ojf8N`#TGI8? zQ=!G09byHAN>eTbJ{MfnZYtx}~*M1u#n}1uVT{O;Muffp(-A-0nmn;Jq=hRXbVwcl1*PXCnJH zsZoy^@J;s!bBp^(llq_RvjTP9D@-cxm&(Nfx& z?3+k6L<)ck6DgHeZc>9AX%z_t05F}g|7EjAB$b +J-CAvFalquAsR`3QL+1kwf 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 KKbLh*2~7ZRX09^; literal 0 HcmV?d00001 diff --git a/docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-flow-run.png b/docs/website/docs/walkthroughs/deploy-a-pipeline/images/prefect-flow-run.png new file mode 100644 index 0000000000000000000000000000000000000000..ccecc7d80f8fa9c0b2dea36ffdfb7e488e8bdab9 GIT binary patch literal 430165 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 z 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

0Qn{Nh!Hmiu+zw_OiLiY8e?X|Ys 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

Yui$#dBELnpZ8c^- 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ºkZ|s26at+6e2P6buA>ERWtK%QvQ5bMcc+5@{ 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 p>&RAOvu4^j&##VX@4)kn#qu> Date: Wed, 24 Apr 2024 20:01:39 +0200 Subject: [PATCH 37/41] Add max_table_nesting to resource decorator (#1242) * Add max_table_nesting to resource decorator * Handle max_table_nesting in normalizer * Use dict.get to retrieve table from schema * Use schema.get_table and format code * Fix bugs and parametrize test * Add one more test case * Get table from schema.tables * Add comments and cleanup code * Add more test cases when max_table_nesting is overridden via the property setter * Assert property setter sets the value and a test with source and resource with max_table_nesting set * Clarify test scenario description * Add checks if max_nesting set in x-normalizer hints * Add another case when resource does not define but source has defined max_table_nesting * Check max_table_nesting propery accessor * Update resource.md --- dlt/common/normalizers/json/relational.py | 49 ++- dlt/extract/decorators.py | 15 + dlt/extract/resource.py | 11 + docs/website/docs/general-usage/resource.md | 56 +++ tests/normalize/test_max_nesting.py | 371 ++++++++++++++++++++ 5 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 tests/normalize/test_max_nesting.py diff --git a/dlt/common/normalizers/json/relational.py b/dlt/common/normalizers/json/relational.py index da38ac60a7..572bff14a5 100644 --- a/dlt/common/normalizers/json/relational.py +++ b/dlt/common/normalizers/json/relational.py @@ -8,7 +8,6 @@ from dlt.common.typing import DictStrAny, DictStrStr, TDataItem, StrAny from dlt.common.schema import Schema from dlt.common.schema.typing import ( - TTableSchema, TColumnSchema, TColumnName, TSimpleRegex, @@ -32,7 +31,7 @@ class TDataItemRow(TypedDict, total=False): class TDataItemRowRoot(TDataItemRow, total=False): - _dlt_load_id: str # load id to identify records loaded together that ie. need to be processed + _dlt_load_id: (str) # load id to identify records loaded together that ie. need to be processed # _dlt_meta: TEventDLTMeta # stores metadata, should never be sent to the normalizer @@ -67,7 +66,9 @@ def __init__(self, schema: Schema) -> None: self._reset() def _reset(self) -> None: - self.normalizer_config = self.schema._normalizers_config["json"].get("config") or {} # type: ignore + self.normalizer_config = ( + self.schema._normalizers_config["json"].get("config") or {} # type: ignore[assignment] + ) self.propagation_config = self.normalizer_config.get("propagation", None) self.max_nesting = self.normalizer_config.get("max_nesting", 1000) self._skip_primary_key = {} @@ -79,10 +80,14 @@ def _is_complex_type(self, table_name: str, field_name: str, _r_lvl: int) -> boo # turn everything at the recursion level into complex type max_nesting = self.max_nesting schema = self.schema + max_table_nesting = self._get_table_nesting_level(schema, table_name) + if max_table_nesting is not None: + max_nesting = max_table_nesting assert _r_lvl <= max_nesting if _r_lvl == max_nesting: return True + # use cached value # path = f"{table_name}▶{field_name}" # or use definition in the schema @@ -94,6 +99,7 @@ def _is_complex_type(self, table_name: str, field_name: str, _r_lvl: int) -> boo data_type = schema.get_preferred_type(field_name) else: data_type = column["data_type"] + return data_type == "complex" def _flatten( @@ -220,7 +226,13 @@ def _normalize_list( elif isinstance(v, list): # to normalize lists of lists, we must create a tracking intermediary table by creating a mock row yield from self._normalize_row( - {"list": v}, extend, ident_path, parent_path, parent_row_id, idx, _r_lvl + 1 + {"list": v}, + extend, + ident_path, + parent_path, + parent_row_id, + idx, + _r_lvl + 1, ) else: # list of simple types @@ -261,20 +273,29 @@ def _normalize_row( extend.update(self._get_propagated_values(table, flattened_row, _r_lvl)) # yield parent table first - should_descend = yield (table, schema.naming.shorten_fragments(*parent_path)), flattened_row + should_descend = yield ( + (table, schema.naming.shorten_fragments(*parent_path)), + flattened_row, + ) if should_descend is False: return # normalize and yield lists for list_path, list_content in lists.items(): yield from self._normalize_list( - list_content, extend, list_path, parent_path + ident_path, row_id, _r_lvl + 1 + list_content, + extend, + list_path, + parent_path + ident_path, + row_id, + _r_lvl + 1, ) def extend_schema(self) -> None: # validate config config = cast( - RelationalNormalizerConfig, self.schema._normalizers_config["json"].get("config") or {} + RelationalNormalizerConfig, + self.schema._normalizers_config["json"].get("config") or {}, ) DataItemNormalizer._validate_normalizer_config(self.schema, config) @@ -366,6 +387,14 @@ def _validate_normalizer_config(schema: Schema, config: RelationalNormalizerConf validator_f=column_name_validator(schema.naming), ) + @staticmethod + @lru_cache(maxsize=None) + def _get_table_nesting_level(schema: Schema, table_name: str) -> Optional[int]: + table = schema.tables.get(table_name) + if table: + return table.get("x-normalizer", {}).get("max_nesting") # type: ignore + return None + @staticmethod @lru_cache(maxsize=None) def _is_scd2_table(schema: Schema, table_name: str) -> bool: @@ -382,7 +411,11 @@ def _get_validity_column_names(schema: Schema, table_name: str) -> List[Optional @staticmethod @lru_cache(maxsize=None) def _dlt_id_is_row_hash(schema: Schema, table_name: str) -> bool: - return schema.get_table(table_name)["columns"].get("_dlt_id", dict()).get("x-row-version", False) # type: ignore[return-value] + return ( + schema.get_table(table_name)["columns"] # type: ignore[return-value] + .get("_dlt_id", {}) + .get("x-row-version", False) + ) @staticmethod def _validate_validity_column_names( diff --git a/dlt/extract/decorators.py b/dlt/extract/decorators.py index bc85cb4a03..7230c48516 100644 --- a/dlt/extract/decorators.py +++ b/dlt/extract/decorators.py @@ -286,6 +286,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, + max_table_nesting: int = None, write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, @@ -304,6 +305,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, + max_table_nesting: int = None, write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, @@ -322,6 +324,7 @@ def resource( /, name: TTableHintTemplate[str] = None, table_name: TTableHintTemplate[str] = None, + max_table_nesting: int = None, write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, @@ -341,6 +344,7 @@ def resource( /, name: str = None, table_name: TTableHintTemplate[str] = None, + max_table_nesting: int = None, write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, @@ -358,6 +362,7 @@ def resource( /, name: TTableHintTemplate[str] = None, table_name: TTableHintTemplate[str] = None, + max_table_nesting: int = None, write_disposition: TTableHintTemplate[TWriteDispositionConfig] = None, columns: TTableHintTemplate[TAnySchemaColumns] = None, primary_key: TTableHintTemplate[TColumnNames] = None, @@ -398,6 +403,7 @@ def resource( If not present, the name of the decorated function will be used. table_name (TTableHintTemplate[str], optional): An table name, if different from `name`. + max_table_nesting (int, optional): A schema hint that sets the maximum depth of nested table above which the remaining nodes are loaded as structs or JSON. This argument also accepts a callable that is used to dynamically create tables for stream-like resources yielding many datatypes. write_disposition (TTableHintTemplate[TWriteDispositionConfig], optional): Controls how to write data to a table. Accepts a shorthand string literal or configuration dictionary. @@ -449,6 +455,15 @@ def make_resource( schema_contract=schema_contract, table_format=table_format, ) + + # If custom nesting level was specified then + # we need to add it to table hints so that + # later in normalizer dlt/common/normalizers/json/relational.py + # we can override max_nesting level for the given table + if max_table_nesting is not None: + table_template.setdefault("x-normalizer", {}) # type: ignore[typeddict-item] + table_template["x-normalizer"]["max_nesting"] = max_table_nesting # type: ignore[typeddict-item] + resource = DltResource.from_data( _data, _name, diff --git a/dlt/extract/resource.py b/dlt/extract/resource.py index 4776158bbb..7f4eb05d6f 100644 --- a/dlt/extract/resource.py +++ b/dlt/extract/resource.py @@ -215,6 +215,17 @@ def validator(self, validator: Optional[ValidateItem]) -> None: if validator: self.add_step(validator, insert_at=step_no if step_no >= 0 else None) + @property + def max_table_nesting(self) -> Optional[int]: + """A schema hint for resource that sets the maximum depth of nested table above which the remaining nodes are loaded as structs or JSON.""" + max_nesting = self._hints.get("x-normalizer", {}).get("max_nesting") # type: ignore[attr-defined] + return max_nesting if isinstance(max_nesting, int) else None + + @max_table_nesting.setter + def max_table_nesting(self, value: int) -> None: + self._hints.setdefault("x-normalizer", {}) # type: ignore[typeddict-item] + self._hints["x-normalizer"]["max_nesting"] = value # type: ignore[typeddict-item] + def pipe_data_from(self, data_from: Union["DltResource", Pipe]) -> None: """Replaces the parent in the transformer resource pipe from which the data is piped.""" if self.is_transformer: diff --git a/docs/website/docs/general-usage/resource.md b/docs/website/docs/general-usage/resource.md index 66c4281d8d..3ab485486e 100644 --- a/docs/website/docs/general-usage/resource.md +++ b/docs/website/docs/general-usage/resource.md @@ -343,6 +343,62 @@ for user in users().add_filter(lambda user: user["user_id"] != "me").add_map(ano print(user) ``` +### Reduce the nesting level of generated tables + +You can limit how deep `dlt` goes when generating child tables. By default, the library will descend +and generate child tables for all nested lists, without limit. + +:::note +`max_table_nesting` is optional so you can skip it, in this case dlt will +use it from the source if it is specified there or fallback to default +value which has 1000 as maximum nesting level. +::: + +```py +import dlt + +@dlt.resource(max_table_nesting=1) +def my_resource(): + yield { + "id": 1, + "name": "random name", + "properties": [ + { + "name": "customer_age", + "type": "int", + "label": "Age", + "notes": [ + { + "text": "string", + "author": "string", + } + ] + } + ] + } +``` + +In the example above we want only 1 level of child tables to be generated (so there are no child +tables of child tables). Typical settings: + +- `max_table_nesting=0` will not generate child tables at all and all nested data will be + represented as json. +- `max_table_nesting=1` will generate child tables of top level tables and nothing more. All nested + data in child tables will be represented as json. + +You can achieve the same effect after the resource instance is created: + +```py +from my_resource import my_awesome_module + +resource = my_resource() +resource.max_table_nesting = 0 +``` + +Several data sources are prone to contain semi-structured documents with very deep nesting i.e. +MongoDB databases. Our practical experience is that setting the `max_nesting_level` to 2 or 3 +produces the clearest and human-readable schemas. + ### Sample from large data If your resource loads thousands of pages of data from a REST API or millions of rows from a db diff --git a/tests/normalize/test_max_nesting.py b/tests/normalize/test_max_nesting.py new file mode 100644 index 0000000000..4015836232 --- /dev/null +++ b/tests/normalize/test_max_nesting.py @@ -0,0 +1,371 @@ +from typing import Any, Dict, List + +import dlt +import pytest + +from dlt.common import json +from dlt.destinations import dummy +from tests.common.utils import json_case_path + + +TOP_LEVEL_TABLES = ["bot_events"] + +ALL_TABLES_FOR_RASA_EVENT = [ + "bot_events", + "bot_events__metadata__known_recipients", + "bot_events__metadata__transaction_history__spend__target", + "bot_events__metadata__transaction_history__spend__starbucks", + "bot_events__metadata__transaction_history__spend__amazon", + "bot_events__metadata__transaction_history__deposit__employer", + "bot_events__metadata__transaction_history__deposit__interest", + "bot_events__metadata__vendor_list", +] + +ALL_TABLES_FOR_RASA_EVENT_NESTING_LEVEL_2 = [ + "bot_events", + "bot_events__metadata__known_recipients", + "bot_events__metadata__vendor_list", +] + + +@pytest.fixture(scope="module") +def rasa_event_bot_metadata(): + with open(json_case_path("rasa_event_bot_metadata"), "rb") as f: + return json.load(f) + + +@pytest.mark.parametrize( + "nesting_level,expected_num_tables,expected_table_names", + ( + (0, 1, TOP_LEVEL_TABLES), + (1, 1, TOP_LEVEL_TABLES), + (2, 3, ALL_TABLES_FOR_RASA_EVENT_NESTING_LEVEL_2), + (5, 8, ALL_TABLES_FOR_RASA_EVENT), + (15, 8, ALL_TABLES_FOR_RASA_EVENT), + (25, 8, ALL_TABLES_FOR_RASA_EVENT), + (1000, 8, ALL_TABLES_FOR_RASA_EVENT), + ), +) +def test_resource_max_nesting( + nesting_level: int, + expected_num_tables: int, + expected_table_names: List[str], + rasa_event_bot_metadata: Dict[str, Any], +): + @dlt.resource(max_table_nesting=nesting_level) + def bot_events(): + yield rasa_event_bot_metadata + + assert "x-normalizer" in bot_events._hints + + pipeline_name = f"test_max_table_nesting_{nesting_level}_{expected_num_tables}" + pipeline = dlt.pipeline( + pipeline_name=pipeline_name, + destination=dummy(timeout=0.1), + full_refresh=True, + ) + + pipeline.run(bot_events) + assert pipeline.schemas.keys() + assert pipeline_name in pipeline.schema_names + + pipeline_schema = pipeline.schemas[pipeline_name] + assert len(pipeline_schema.data_table_names()) == expected_num_tables + + all_table_names = pipeline_schema.data_table_names() + for table_name in expected_table_names: + assert table_name in all_table_names + + +def test_with_multiple_resources_with_max_table_nesting_levels( + rasa_event_bot_metadata: Dict[str, Any], +): + """Test max_table_nesting feature with multiple resources and a source + Test scenario includes + + 1. Testing three different sources with set and unset `max_table_nesting` parameter + and checks if the number of created tables in the schema match the expected numbers + and the exact list table names have been collected; + 2. For the same parent source we change the `max_table_nesting` and verify if it is respected + by the third resource `third_resource_with_nested_data` as well as checking + the number of created tables in the current schema; + 3. Run combined test where we set `max_table_nesting` for the parent source and check + if this `max_table_nesting` is respected by child resources where they don't define their + own nesting level; + 4. Run the pipeline with set `max_table_nesting` of a resource then override it and + rerun the pipeline to check if the number and names of tables are expected; + 5. Create source and resource both with defined `max_nesting_level` and check if we respect + `max_nesting_level` from resource; + """ + + @dlt.resource(max_table_nesting=1) + def rasa_bot_events_with_nesting_lvl_one(): + yield rasa_event_bot_metadata + + @dlt.resource(max_table_nesting=2) + def rasa_bot_events_with_nesting_lvl_two(): + yield rasa_event_bot_metadata + + all_table_names_for_third_resource = [ + "third_resource_with_nested_data", + "third_resource_with_nested_data__payload__hints", + "third_resource_with_nested_data__payload__hints__f_float", + "third_resource_with_nested_data__payload__hints__f_float__comments", + "third_resource_with_nested_data__params", + ] + + @dlt.resource + def third_resource_with_nested_data(): # first top level table `third_resource_with_nested_data` + yield [ + { + "id": 1, + "payload": { + "f_int": 7817289713, + "f_float": 878172.8292, + "f_timestamp": "2024-04-19T11:40:32.901899+00:00", + "f_bool": False, + "hints": [ # second table `third_resource_with_nested_data__payload__hints` + { + "f_bool": "bool", + "f_timestamp": "bigint", + "f_float": [ # third table `third_resource_with_nested_data__payload__hints__f_float` + { + "cond": "precision > 4", + "then": "decimal", + "else": "float", + "comments": [ # fourth table `third_resource_with_nested_data__payload__hints__f_float__comments` + { + "text": "blabla bla bla we promise magix", + "author": "bart", + } + ], + } + ], + } + ], + }, + "params": [{"id": 1, "q": "search"}, {"id": 2, "q": "hashtag-search"}], + } + ] + + assert "x-normalizer" in rasa_bot_events_with_nesting_lvl_one._hints + assert "x-normalizer" in rasa_bot_events_with_nesting_lvl_two._hints + assert rasa_bot_events_with_nesting_lvl_one.max_table_nesting == 1 + assert rasa_bot_events_with_nesting_lvl_two.max_table_nesting == 2 + assert rasa_bot_events_with_nesting_lvl_one._hints["x-normalizer"]["max_nesting"] == 1 # type: ignore[typeddict-item] + assert rasa_bot_events_with_nesting_lvl_two._hints["x-normalizer"]["max_nesting"] == 2 # type: ignore[typeddict-item] + assert "x-normalizer" not in third_resource_with_nested_data._hints + + # Check scenario #1 + @dlt.source(max_table_nesting=100) + def some_data(): + return [ + rasa_bot_events_with_nesting_lvl_one(), + rasa_bot_events_with_nesting_lvl_two(), + third_resource_with_nested_data(), + ] + + pipeline_name = "test_different_table_nesting_levels" + pipeline = dlt.pipeline( + pipeline_name=pipeline_name, + destination=dummy(timeout=0.1), + full_refresh=True, + ) + + pipeline.run(some_data(), write_disposition="append") + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + + # expect only one table for resource `rasa_bot_events_with_nesting_lvl_one` + tables = [tbl for tbl in all_table_names if tbl.endswith("nesting_lvl_one")] + assert len(tables) == 1 + assert tables == ["rasa_bot_events_with_nesting_lvl_one"] + + # expect three tables for resource `rasa_bot_events_with_nesting_lvl_two` + tables = [tbl for tbl in all_table_names if "nesting_lvl_two" in tbl] + assert len(tables) == 3 + assert tables == [ + "rasa_bot_events_with_nesting_lvl_two", + "rasa_bot_events_with_nesting_lvl_two__metadata__known_recipients", + "rasa_bot_events_with_nesting_lvl_two__metadata__vendor_list", + ] + + # expect four tables for resource `third_resource_with_nested_data` + tables = [tbl for tbl in all_table_names if "third_resource" in tbl] + assert len(tables) == 5 + assert tables == all_table_names_for_third_resource + + # Check scenario #2 + # now we need to check `third_resource_with_nested_data` + # using different nesting levels at the source level + # First we do with max_table_nesting=0 + @dlt.source(max_table_nesting=0) + def some_data_v2(): + yield third_resource_with_nested_data() + + pipeline.drop() + pipeline.run(some_data_v2(), write_disposition="append") + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + assert len(all_table_names) == 1 + assert all_table_names == [ + "third_resource_with_nested_data", + ] + + # Second we do with max_table_nesting=1 + some_data_source = some_data_v2() + some_data_source.max_table_nesting = 1 + + pipeline.drop() + pipeline.run(some_data_source, write_disposition="append") + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + assert len(all_table_names) == 2 + assert all_table_names == [ + "third_resource_with_nested_data", + "third_resource_with_nested_data__params", + ] + + # Second we do with max_table_nesting=2 + some_data_source = some_data_v2() + some_data_source.max_table_nesting = 3 + + pipeline.drop() + pipeline.run(some_data_source, write_disposition="append") + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + + # 5 because payload is a dictionary not a collection of dictionaries + assert len(all_table_names) == 5 + assert all_table_names == all_table_names_for_third_resource + + # Check scenario #3 + pipeline.drop() + some_data_source = some_data() + some_data_source.max_table_nesting = 0 + pipeline.run(some_data_source, write_disposition="append") + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + assert len(all_table_names) == 5 + assert sorted(all_table_names) == [ + "rasa_bot_events_with_nesting_lvl_one", + "rasa_bot_events_with_nesting_lvl_two", + "rasa_bot_events_with_nesting_lvl_two__metadata__known_recipients", + "rasa_bot_events_with_nesting_lvl_two__metadata__vendor_list", + "third_resource_with_nested_data", + ] + + # Check scenario #4 + # Set max_table_nesting via the setter and check the tables + pipeline.drop() + rasa_bot_events_resource = rasa_bot_events_with_nesting_lvl_one() + pipeline.run( + rasa_bot_events_resource, + dataset_name="bot_events", + write_disposition="append", + ) + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + all_table_names = pipeline_schema.data_table_names() + count_all_tables_first_run = len(all_table_names) + tables = pipeline_schema.data_table_names() + assert count_all_tables_first_run == 1 + assert tables == ["rasa_bot_events_with_nesting_lvl_one"] + + # now adjust the max_table_nesting for resource and check + pipeline.drop() + rasa_bot_events_resource.max_table_nesting = 2 + assert rasa_bot_events_resource.max_table_nesting == 2 + pipeline.run( + rasa_bot_events_resource, + dataset_name="bot_events", + write_disposition="append", + ) + all_table_names = pipeline_schema.data_table_names() + count_all_tables_second_run = len(all_table_names) + assert count_all_tables_first_run < count_all_tables_second_run + + tables = pipeline_schema.data_table_names() + assert count_all_tables_second_run == 3 + assert tables == [ + "rasa_bot_events_with_nesting_lvl_one", + "rasa_bot_events_with_nesting_lvl_one__metadata__known_recipients", + "rasa_bot_events_with_nesting_lvl_one__metadata__vendor_list", + ] + + pipeline.drop() + rasa_bot_events_resource.max_table_nesting = 10 + assert rasa_bot_events_resource.max_table_nesting == 10 + pipeline.run(rasa_bot_events_resource, dataset_name="bot_events") + all_table_names = pipeline_schema.data_table_names() + count_all_tables_second_run = len(all_table_names) + assert count_all_tables_first_run < count_all_tables_second_run + + tables = pipeline_schema.data_table_names() + assert count_all_tables_second_run == 8 + assert tables == [ + "rasa_bot_events_with_nesting_lvl_one", + "rasa_bot_events_with_nesting_lvl_one__metadata__known_recipients", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__target", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__starbucks", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__amazon", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__deposit__employer", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__deposit__interest", + "rasa_bot_events_with_nesting_lvl_one__metadata__vendor_list", + ] + + pipeline.drop() + third_resource_with_nested_data.max_table_nesting = 10 + assert third_resource_with_nested_data.max_table_nesting == 10 + pipeline.run(third_resource_with_nested_data) + all_table_names = pipeline_schema.data_table_names() + count_all_tables_second_run = len(all_table_names) + assert count_all_tables_first_run < count_all_tables_second_run + + tables_with_nesting_level_set = pipeline_schema.data_table_names() + assert count_all_tables_second_run == 5 + assert tables_with_nesting_level_set == all_table_names_for_third_resource + + # Set max_table_nesting=None and check if the same tables exist + third_resource_with_nested_data.max_table_nesting = None + assert third_resource_with_nested_data.max_table_nesting is None + pipeline.run(third_resource_with_nested_data) + all_table_names = pipeline_schema.data_table_names() + count_all_tables_second_run = len(all_table_names) + assert count_all_tables_first_run < count_all_tables_second_run + + tables = pipeline_schema.data_table_names() + assert count_all_tables_second_run == 5 + assert tables == all_table_names_for_third_resource + assert tables == tables_with_nesting_level_set + + # Check scenario #5 + # We give priority `max_table_nesting` of the resource if it is defined + @dlt.source(max_table_nesting=1000) + def some_data_with_table_nesting(): + yield rasa_bot_events_with_nesting_lvl_one() + + pipeline.drop() + pipeline.run(some_data_with_table_nesting()) + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + tables = pipeline_schema.data_table_names() + assert len(tables) == 1 + assert tables == ["rasa_bot_events_with_nesting_lvl_one"] + + # Now check the case when `max_table_nesting` is not defined in the resource + rasa_bot_events_with_nesting_lvl_one.max_table_nesting = None + + pipeline.drop() + pipeline.run(some_data_with_table_nesting()) + pipeline_schema = pipeline.schemas[pipeline.default_schema_name] + tables = pipeline_schema.data_table_names() + assert len(tables) == 8 + assert tables == [ + "rasa_bot_events_with_nesting_lvl_one", + "rasa_bot_events_with_nesting_lvl_one__metadata__known_recipients", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__target", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__starbucks", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__spend__amazon", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__deposit__employer", + "rasa_bot_events_with_nesting_lvl_one__metadata__transaction_history__deposit__interest", + "rasa_bot_events_with_nesting_lvl_one__metadata__vendor_list", + ] From 4763496fbe2b15a49e150f35c08c50af1132048b Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:05:26 +0200 Subject: [PATCH 38/41] Create additional folders when coping files on local filesystem (#1263) * Automatically create folders for local filesystem * Use simple equals check for protocol==file --- .../impl/filesystem/filesystem.py | 8 ++++++ .../load/pipeline/test_filesystem_pipeline.py | 27 ++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/dlt/destinations/impl/filesystem/filesystem.py b/dlt/destinations/impl/filesystem/filesystem.py index 5dae4bf295..debfda06dc 100644 --- a/dlt/destinations/impl/filesystem/filesystem.py +++ b/dlt/destinations/impl/filesystem/filesystem.py @@ -73,7 +73,15 @@ def __init__( load_package_timestamp=dlt.current.load_package()["state"]["created_at"], # type: ignore extra_placeholders=config.extra_placeholders, ) + + # We would like to avoid failing for local filesystem where + # deeply nested directory will not exist before writing a file. + # It `auto_mkdir` is disabled by default in fsspec so we made some + # trade offs between different options and decided on this. item = self.make_remote_path() + if self.config.protocol == "file": + fs_client.makedirs(posixpath.dirname(item), exist_ok=True) + fs_client.put_file(local_path, item) def make_remote_path(self) -> str: diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index c08bd488ef..28c17e14dd 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -3,7 +3,6 @@ import posixpath from pathlib import Path from typing import Any, Callable, List, Dict, cast - import dlt import pytest @@ -13,7 +12,6 @@ from dlt.common.storages.load_storage import LoadJobInfo from dlt.destinations import filesystem from dlt.destinations.impl.filesystem.filesystem import FilesystemClient -from dlt.common.schema.typing import LOADS_TABLE_NAME from tests.cases import arrow_table_all_data_types from tests.common.utils import load_json_case @@ -237,7 +235,9 @@ def some_source(): @pytest.mark.parametrize("layout", TEST_LAYOUTS) -def test_filesystem_destination_extended_layout_placeholders(layout: str) -> None: +def test_filesystem_destination_extended_layout_placeholders( + layout: str, default_buckets_env: str +) -> None: data = load_json_case("simple_row") call_count = 0 @@ -258,30 +258,31 @@ def count(*args, **kwargs) -> Any: "hiphip": counter("Hurraaaa"), } now = pendulum.now() - os.environ["DESTINATION__FILESYSTEM__BUCKET_URL"] = "file://_storage" + fs_destination = filesystem( + layout=layout, + extra_placeholders=extra_placeholders, + current_datetime=counter(now), + ) pipeline = dlt.pipeline( pipeline_name="test_extended_layouts", - destination=filesystem( - layout=layout, - extra_placeholders=extra_placeholders, - kwargs={"auto_mkdir": True}, - current_datetime=counter(now), - ), + destination=fs_destination, ) load_info = pipeline.run( dlt.resource(data, name="simple_rows"), write_disposition="append", ) client = pipeline.destination_client() + expected_files = set() known_files = set() for basedir, _dirs, files in client.fs_client.walk(client.dataset_path): # type: ignore[attr-defined] # strip out special tables if "_dlt" in basedir: continue + for file in files: if ".jsonl" in file: - expected_files.add(os.path.join(basedir, file)) + expected_files.add(posixpath.join(basedir, file)) for load_package in load_info.load_packages: for load_info in load_package.jobs["completed_jobs"]: # type: ignore[assignment] @@ -298,8 +299,8 @@ def count(*args, **kwargs) -> Any: load_package_timestamp=load_info.created_at.to_iso8601_string(), # type: ignore[attr-defined] extra_placeholders=extra_placeholders, ) - full_path = os.path.join(client.dataset_path, path) # type: ignore[attr-defined] - assert os.path.exists(full_path) + full_path = posixpath.join(client.dataset_path, path) # type: ignore[attr-defined] + assert client.fs_client.exists(full_path) # type: ignore[attr-defined] if ".jsonl" in full_path: known_files.add(full_path) From 5d296bcf4ce00a7e67b03c01864e3cfe42dc0986 Mon Sep 17 00:00:00 2001 From: Sultan Iman <354868+sultaniman@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:06:06 +0200 Subject: [PATCH 39/41] Add snowflake to application parameter to configuration (#1266) * Add snowflake to application parameter to configuration * Set default application parameter if it is not specified * Adjust tests and for connection params * Use empty string to skip setting the application parameter * Set default value for application parameter * Fix if check bug * Uppercase SNOWFLAKE_APPLICATION_ID and re-use in tests * Add note in docs about application parameter for snowflake * Update text for snowflake's application connection parameter * Fix typo * Update docs/website/docs/dlt-ecosystem/destinations/snowflake.md Co-authored-by: VioletM * Update snowflake.md * Update doc --------- Co-authored-by: VioletM --- .../impl/snowflake/configuration.py | 14 +++++++++ .../dlt-ecosystem/destinations/snowflake.md | 31 ++++++++++--------- .../snowflake/test_snowflake_configuration.py | 31 ++++++++++++++++++- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/dlt/destinations/impl/snowflake/configuration.py b/dlt/destinations/impl/snowflake/configuration.py index 5a1f7a65a9..c8cc805712 100644 --- a/dlt/destinations/impl/snowflake/configuration.py +++ b/dlt/destinations/impl/snowflake/configuration.py @@ -49,6 +49,9 @@ def _read_private_key(private_key: str, password: Optional[str] = None) -> bytes ) +SNOWFLAKE_APPLICATION_ID = "dltHub_dlt" + + @configspec(init=False) class SnowflakeCredentials(ConnectionStringCredentials): drivername: Final[str] = dataclasses.field(default="snowflake", init=False, repr=False, compare=False) # type: ignore[misc] @@ -60,6 +63,7 @@ class SnowflakeCredentials(ConnectionStringCredentials): authenticator: Optional[str] = None private_key: Optional[TSecretStrValue] = None private_key_passphrase: Optional[TSecretStrValue] = None + application: Optional[str] = SNOWFLAKE_APPLICATION_ID __config_gen_annotations__: ClassVar[List[str]] = ["password", "warehouse", "role"] @@ -85,6 +89,10 @@ def to_url(self) -> URL: query["warehouse"] = self.warehouse if self.role and "role" not in query: query["role"] = self.role + + if self.application != "" and "application" not in query: + query["application"] = self.application + return URL.create( self.drivername, self.username, @@ -99,6 +107,7 @@ def to_connector_params(self) -> Dict[str, Any]: private_key: Optional[bytes] = None if self.private_key: private_key = _read_private_key(self.private_key, self.private_key_passphrase) + conn_params = dict( self.query or {}, user=self.username, @@ -109,8 +118,13 @@ def to_connector_params(self) -> Dict[str, Any]: role=self.role, private_key=private_key, ) + if self.authenticator: conn_params["authenticator"] = self.authenticator + + if self.application != "" and "application" not in conn_params: + conn_params["application"] = self.application + return conn_params diff --git a/docs/website/docs/dlt-ecosystem/destinations/snowflake.md b/docs/website/docs/dlt-ecosystem/destinations/snowflake.md index 8ba6934313..f144da02e6 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/snowflake.md +++ b/docs/website/docs/dlt-ecosystem/destinations/snowflake.md @@ -6,8 +6,8 @@ keywords: [Snowflake, destination, data warehouse] # Snowflake -## Install dlt with Snowflake -**To install the dlt library with Snowflake dependencies, run:** +## Install `dlt` with Snowflake +**To install the `dlt` library with Snowflake dependencies, run:** ```sh pip install dlt[snowflake] ``` @@ -25,7 +25,7 @@ pip install -r requirements.txt ``` This will install `dlt` with the `snowflake` extra, which contains the Snowflake Python dbapi client. -**3. Create a new database, user, and give dlt access.** +**3. Create a new database, user, and give `dlt` access.** Read the next chapter below. @@ -44,7 +44,6 @@ In the case of Snowflake, the **host** is your [Account Identifier](https://docs The **warehouse** and **role** are optional if you assign defaults to your user. In the example below, we do not do that, so we set them explicitly. - ### Setup the database user and permissions The instructions below assume that you use the default account setup that you get after creating a Snowflake account. You should have a default warehouse named **COMPUTE_WH** and a Snowflake account. Below, we create a new database, user, and assign permissions. The permissions are very generous. A more experienced user can easily reduce `dlt` permissions to just one schema in the database. ```sql @@ -57,7 +56,7 @@ CREATE ROLE DLT_LOADER_ROLE; GRANT ROLE DLT_LOADER_ROLE TO USER loader; -- give database access to new role GRANT USAGE ON DATABASE dlt_data TO DLT_LOADER_ROLE; --- allow dlt to create new schemas +-- allow `dlt` to create new schemas GRANT CREATE SCHEMA ON DATABASE dlt_data TO ROLE DLT_LOADER_ROLE -- allow access to a warehouse named COMPUTE_WH GRANT USAGE ON WAREHOUSE COMPUTE_WH TO DLT_LOADER_ROLE; @@ -143,22 +142,22 @@ Names of tables and columns in [schemas](../../general-usage/schema.md) are kept ## Staging support -Snowflake supports S3 and GCS as file staging destinations. dlt will upload files in the parquet format to the bucket provider and will ask Snowflake to copy their data directly into the db. +Snowflake supports S3 and GCS as file staging destinations. `dlt` will upload files in the parquet format to the bucket provider and will ask Snowflake to copy their data directly into the db. Alternatively to parquet files, you can also specify jsonl as the staging file format. For this, set the `loader_file_format` argument of the `run` command of the pipeline to `jsonl`. ### Snowflake and Amazon S3 -Please refer to the [S3 documentation](./filesystem.md#aws-s3) to learn how to set up your bucket with the bucket_url and credentials. For S3, the dlt Redshift loader will use the AWS credentials provided for S3 to access the S3 bucket if not specified otherwise (see config options below). Alternatively, you can create a stage for your S3 Bucket by following the instructions provided in the [Snowflake S3 documentation](https://docs.snowflake.com/en/user-guide/data-load-s3-config-storage-integration). +Please refer to the [S3 documentation](./filesystem.md#aws-s3) to learn how to set up your bucket with the bucket_url and credentials. For S3, the `dlt` Redshift loader will use the AWS credentials provided for S3 to access the S3 bucket if not specified otherwise (see config options below). Alternatively, you can create a stage for your S3 Bucket by following the instructions provided in the [Snowflake S3 documentation](https://docs.snowflake.com/en/user-guide/data-load-s3-config-storage-integration). The basic steps are as follows: * Create a storage integration linked to GCS and the right bucket * Grant access to this storage integration to the Snowflake role you are using to load the data into Snowflake. * Create a stage from this storage integration in the PUBLIC namespace, or the namespace of the schema of your data. * Also grant access to this stage for the role you are using to load data into Snowflake. -* Provide the name of your stage (including the namespace) to dlt like so: +* Provide the name of your stage (including the namespace) to `dlt` like so: -To prevent dlt from forwarding the S3 bucket credentials on every command, and set your S3 stage, change these settings: +To prevent `dlt` from forwarding the S3 bucket credentials on every command, and set your S3 stage, change these settings: ```toml [destination] @@ -168,7 +167,7 @@ stage_name="PUBLIC.my_s3_stage" To run Snowflake with S3 as the staging destination: ```py -# Create a dlt pipeline that will load +# Create a `dlt` pipeline that will load # chess player data to the Snowflake destination # via staging on S3 pipeline = dlt.pipeline( @@ -187,7 +186,7 @@ Please refer to the [Google Storage filesystem documentation](./filesystem.md#go * Grant access to this storage integration to the Snowflake role you are using to load the data into Snowflake. * Create a stage from this storage integration in the PUBLIC namespace, or the namespace of the schema of your data. * Also grant access to this stage for the role you are using to load data into Snowflake. -* Provide the name of your stage (including the namespace) to dlt like so: +* Provide the name of your stage (including the namespace) to `dlt` like so: ```toml [destination] @@ -197,7 +196,7 @@ stage_name="PUBLIC.my_gcs_stage" To run Snowflake with GCS as the staging destination: ```py -# Create a dlt pipeline that will load +# Create a `dlt` pipeline that will load # chess player data to the Snowflake destination # via staging on GCS pipeline = dlt.pipeline( @@ -218,7 +217,7 @@ Please consult the Snowflake Documentation on [how to create a stage for your Az * Grant access to this storage integration to the Snowflake role you are using to load the data into Snowflake. * Create a stage from this storage integration in the PUBLIC namespace, or the namespace of the schema of your data. * Also grant access to this stage for the role you are using to load data into Snowflake. -* Provide the name of your stage (including the namespace) to dlt like so: +* Provide the name of your stage (including the namespace) to `dlt` like so: ```toml [destination] @@ -228,7 +227,7 @@ stage_name="PUBLIC.my_azure_stage" To run Snowflake with Azure as the staging destination: ```py -# Create a dlt pipeline that will load +# Create a `dlt` pipeline that will load # chess player data to the Snowflake destination # via staging on Azure pipeline = dlt.pipeline( @@ -255,5 +254,9 @@ This destination [integrates with dbt](../transformations/dbt/dbt.md) via [dbt-s ### Syncing of `dlt` state This destination fully supports [dlt state sync](../../general-usage/state#syncing-state-with-destination) +### Snowflake connection identifier +We enable Snowflake to identify that the connection is created by `dlt`. Snowflake will use this identifier to better understand the usage patterns +associated with `dlt` integration. The connection identifier is `dltHub_dlt`. + diff --git a/tests/load/snowflake/test_snowflake_configuration.py b/tests/load/snowflake/test_snowflake_configuration.py index 2add5c0017..610aab7c20 100644 --- a/tests/load/snowflake/test_snowflake_configuration.py +++ b/tests/load/snowflake/test_snowflake_configuration.py @@ -10,6 +10,7 @@ from dlt.common.utils import digest128 from dlt.destinations.impl.snowflake.configuration import ( + SNOWFLAKE_APPLICATION_ID, SnowflakeClientConfiguration, SnowflakeCredentials, ) @@ -21,7 +22,7 @@ def test_connection_string_with_all_params() -> None: - url = "snowflake://user1:pass1@host1/db1?warehouse=warehouse1&role=role1&private_key=cGs%3D&private_key_passphrase=paphr" + url = "snowflake://user1:pass1@host1/db1?application=dltHub_dlt&warehouse=warehouse1&role=role1&private_key=cGs%3D&private_key_passphrase=paphr" creds = SnowflakeCredentials() creds.parse_native_representation(url) @@ -36,9 +37,20 @@ def test_connection_string_with_all_params() -> None: assert creds.private_key_passphrase == "paphr" expected = make_url(url) + to_url_value = str(creds.to_url()) # Test URL components regardless of query param order assert make_url(creds.to_native_representation()) == expected + assert to_url_value == str(expected) + + creds.application = "custom" + url = "snowflake://user1:pass1@host1/db1?application=custom&warehouse=warehouse1&role=role1&private_key=cGs%3D&private_key_passphrase=paphr" + creds.parse_native_representation(url) + expected = make_url(url) + to_url_value = str(creds.to_url()) + assert make_url(creds.to_native_representation()) == expected + assert to_url_value == str(expected) + assert "application=custom" in str(expected) def test_to_connector_params() -> None: @@ -66,6 +78,8 @@ def test_to_connector_params() -> None: password=None, warehouse="warehouse1", role="role1", + # default application identifier will be used + application=SNOWFLAKE_APPLICATION_ID, ) # base64 encoded DER key @@ -79,6 +93,8 @@ def test_to_connector_params() -> None: creds.host = "host1" creds.warehouse = "warehouse1" creds.role = "role1" + # set application identifier and check it + creds.application = "custom_app_id" params = creds.to_connector_params() @@ -92,6 +108,7 @@ def test_to_connector_params() -> None: password=None, warehouse="warehouse1", role="role1", + application="custom_app_id", ) @@ -103,12 +120,14 @@ def test_snowflake_credentials_native_value(environment) -> None: ) # set password via env os.environ["CREDENTIALS__PASSWORD"] = "pass" + os.environ["CREDENTIALS__APPLICATION"] = "dlt" c = resolve_configuration( SnowflakeCredentials(), explicit_value="snowflake://user1@host1/db1?warehouse=warehouse1&role=role1", ) assert c.is_resolved() assert c.password == "pass" + assert "application=dlt" in str(c.to_url()) # # but if password is specified - it is final c = resolve_configuration( SnowflakeCredentials(), @@ -126,6 +145,16 @@ def test_snowflake_credentials_native_value(environment) -> None: ) assert c.is_resolved() assert c.private_key == "pk" + assert "application=dlt" in str(c.to_url()) + + # check with application = "" it should not be in connection string + os.environ["CREDENTIALS__APPLICATION"] = "" + c = resolve_configuration( + SnowflakeCredentials(), + explicit_value="snowflake://user1@host1/db1?warehouse=warehouse1&role=role1", + ) + assert c.is_resolved() + assert "application=" not in str(c.to_url()) def test_snowflake_configuration() -> None: From 0587f36aa0799bf9c28822f83fee9aa7bb1b305e Mon Sep 17 00:00:00 2001 From: David Scharf Date: Wed, 24 Apr 2024 21:50:15 +0200 Subject: [PATCH 40/41] filesystem improvement and fixes (#1274) * clean up pipeline utils a bit * add fs client base interface and use it in tests * make truncating code easier and fix bug in list table files * create truncate method * add some filesystem tests * adds two bug fixes * create dirs in loadjob * make pipeline fs_client function private for now --------- Co-authored-by: rudolfix --- dlt/destinations/fs_client.py | 42 +++ .../impl/filesystem/filesystem.py | 139 ++++---- dlt/pipeline/exceptions.py | 9 + dlt/pipeline/pipeline.py | 19 + .../airflow_tests/test_airflow_wrapper.py | 2 +- .../athena_iceberg/test_athena_iceberg.py | 3 +- tests/load/duckdb/test_duckdb_client.py | 3 +- tests/load/pipeline/test_arrow_loading.py | 3 +- tests/load/pipeline/test_athena.py | 3 +- tests/load/pipeline/test_dbt_helper.py | 2 +- tests/load/pipeline/test_dremio.py | 2 +- tests/load/pipeline/test_duckdb.py | 3 +- .../load/pipeline/test_filesystem_pipeline.py | 119 ++++++- tests/load/pipeline/test_merge_disposition.py | 3 +- tests/load/pipeline/test_pipelines.py | 13 +- .../load/pipeline/test_replace_disposition.py | 6 +- tests/load/pipeline/test_restore_state.py | 3 +- tests/load/pipeline/test_scd2.py | 3 +- tests/load/pipeline/test_stage_loading.py | 2 +- .../test_write_disposition_changes.py | 5 +- tests/load/pipeline/utils.py | 99 ------ tests/pipeline/test_schema_contracts.py | 2 +- tests/pipeline/utils.py | 336 +++++++++++------- 23 files changed, 496 insertions(+), 325 deletions(-) create mode 100644 dlt/destinations/fs_client.py diff --git a/dlt/destinations/fs_client.py b/dlt/destinations/fs_client.py new file mode 100644 index 0000000000..73f3adb534 --- /dev/null +++ b/dlt/destinations/fs_client.py @@ -0,0 +1,42 @@ +from typing import Iterable, cast, Any, List +from abc import ABC, abstractmethod +from fsspec import AbstractFileSystem + + +class FSClientBase(ABC): + fs_client: AbstractFileSystem + + @abstractmethod + def get_table_dir(self, table_name: str) -> str: + """returns directory for given table""" + pass + + @abstractmethod + def get_table_dirs(self, table_names: Iterable[str]) -> List[str]: + """returns directories for given table""" + pass + + @abstractmethod + def list_table_files(self, table_name: str) -> List[str]: + """returns all filepaths for a given table""" + pass + + @abstractmethod + def truncate_tables(self, table_names: List[str]) -> None: + """truncates the given table""" + pass + + def read_bytes(self, path: str, start: Any = None, end: Any = None, **kwargs: Any) -> bytes: + """reads given file to bytes object""" + return cast(bytes, self.fs_client.read_bytes(path, start, end, **kwargs)) + + def read_text( + self, + path: str, + encoding: Any = None, + errors: Any = None, + newline: Any = None, + **kwargs: Any + ) -> str: + """reads given file into string""" + return cast(str, self.fs_client.read_text(path, encoding, errors, newline, **kwargs)) diff --git a/dlt/destinations/impl/filesystem/filesystem.py b/dlt/destinations/impl/filesystem/filesystem.py index debfda06dc..15f81029d5 100644 --- a/dlt/destinations/impl/filesystem/filesystem.py +++ b/dlt/destinations/impl/filesystem/filesystem.py @@ -33,7 +33,7 @@ from dlt.destinations.impl.filesystem.configuration import FilesystemDestinationClientConfiguration from dlt.destinations.job_impl import NewReferenceJob from dlt.destinations import path_utils - +from dlt.destinations.fs_client import FSClientBase INIT_FILE_NAME = "init" FILENAME_SEPARATOR = "__" @@ -81,7 +81,6 @@ def __init__( item = self.make_remote_path() if self.config.protocol == "file": fs_client.makedirs(posixpath.dirname(item), exist_ok=True) - fs_client.put_file(local_path, item) def make_remote_path(self) -> str: @@ -107,7 +106,7 @@ def create_followup_jobs(self, final_state: TLoadJobState) -> List[NewLoadJob]: return jobs -class FilesystemClient(JobClientBase, WithStagingDataset, WithStateSync): +class FilesystemClient(FSClientBase, JobClientBase, WithStagingDataset, WithStateSync): """filesystem client storing jobs in memory""" capabilities: ClassVar[DestinationCapabilitiesContext] = capabilities() @@ -148,49 +147,35 @@ def initialize_storage(self, truncate_tables: Iterable[str] = None) -> None: if truncate_tables and self.fs_client.isdir(self.dataset_path): # get all dirs with table data to delete. the table data are guaranteed to be files in those folders # TODO: when we do partitioning it is no longer the case and we may remove folders below instead - truncated_dirs = self._get_table_dirs(truncate_tables) - # print(f"TRUNCATE {truncated_dirs}") - truncate_prefixes: Set[str] = set() - for table in truncate_tables: - table_prefix = self.table_prefix_layout.format( - schema_name=self.schema.name, table_name=table - ) - truncate_prefixes.add(posixpath.join(self.dataset_path, table_prefix)) - # print(f"TRUNCATE PREFIXES {truncate_prefixes} on {truncate_tables}") - - for truncate_dir in truncated_dirs: - # get files in truncate dirs - # NOTE: glob implementation in fsspec does not look thread safe, way better is to use ls and then filter - # NOTE: without refresh you get random results here - logger.info(f"Will truncate tables in {truncate_dir}") - try: - all_files = self.fs_client.ls(truncate_dir, detail=False, refresh=True) - # logger.debug(f"Found {len(all_files)} CANDIDATE files in {truncate_dir}") - # print(f"in truncate dir {truncate_dir}: {all_files}") - for item in all_files: - # check every file against all the prefixes - for search_prefix in truncate_prefixes: - if item.startswith(search_prefix): - # NOTE: deleting in chunks on s3 does not raise on access denied, file non existing and probably other errors - # print(f"DEL {item}") - try: - # NOTE: must use rm_file to get errors on delete - self.fs_client.rm_file(item) - except NotImplementedError: - # not all filesystem implement the above - self.fs_client.rm(item) - if self.fs_client.exists(item): - raise FileExistsError(item) - except FileNotFoundError: - logger.info( - f"Directory or path to truncate tables {truncate_dir} does not exist but it" - " should be created previously!" - ) + logger.info(f"Will truncate tables {truncate_tables}") + self.truncate_tables(list(truncate_tables)) # we mark the storage folder as initialized self.fs_client.makedirs(self.dataset_path, exist_ok=True) self.fs_client.touch(posixpath.join(self.dataset_path, INIT_FILE_NAME)) + def truncate_tables(self, table_names: List[str]) -> None: + """Truncate table with given name""" + table_dirs = set(self.get_table_dirs(table_names)) + table_prefixes = [self.get_table_prefix(t) for t in table_names] + for table_dir in table_dirs: + for table_file in self.list_files_with_prefixes(table_dir, table_prefixes): + # NOTE: deleting in chunks on s3 does not raise on access denied, file non existing and probably other errors + # print(f"DEL {item}") + try: + # NOTE: must use rm_file to get errors on delete + self.fs_client.rm_file(table_file) + except NotImplementedError: + # not all filesystem implement the above + self.fs_client.rm(table_file) + if self.fs_client.exists(table_file): + raise FileExistsError(table_file) + except FileNotFoundError: + logger.info( + f"Directory or path to truncate tables {table_names} does not exist but" + " it should have been created previously!" + ) + def update_stored_schema( self, only_tables: Iterable[str] = None, @@ -198,7 +183,7 @@ def update_stored_schema( ) -> TSchemaTables: # create destination dirs for all tables table_names = only_tables or self.schema.tables.keys() - dirs_to_create = self._get_table_dirs(table_names) + dirs_to_create = self.get_table_dirs(table_names) for tables_name, directory in zip(table_names, dirs_to_create): self.fs_client.makedirs(directory, exist_ok=True) # we need to mark the folders of the data tables as initialized @@ -211,21 +196,43 @@ def update_stored_schema( return expected_update - def _get_table_dirs(self, table_names: Iterable[str]) -> List[str]: + def get_table_dir(self, table_name: str) -> str: + # dlt tables do not respect layout (for now) + table_prefix = self.get_table_prefix(table_name) + return posixpath.dirname(table_prefix) + + def get_table_prefix(self, table_name: str) -> str: + # dlt tables do not respect layout (for now) + if table_name.startswith(self.schema._dlt_tables_prefix): + table_prefix = posixpath.join(table_name, "") + else: + table_prefix = self.table_prefix_layout.format( + schema_name=self.schema.name, table_name=table_name + ) + return posixpath.join(self.dataset_path, table_prefix) + + def get_table_dirs(self, table_names: Iterable[str]) -> List[str]: """Gets directories where table data is stored.""" - table_dirs: List[str] = [] - for table_name in table_names: - # dlt tables do not respect layout (for now) - if table_name in self.schema.dlt_table_names(): - table_prefix = posixpath.join(table_name, "") - else: - table_prefix = self.table_prefix_layout.format( - schema_name=self.schema.name, table_name=table_name - ) - destination_dir = posixpath.join(self.dataset_path, table_prefix) - # extract the path component - table_dirs.append(posixpath.dirname(destination_dir)) - return table_dirs + return [self.get_table_dir(t) for t in table_names] + + def list_table_files(self, table_name: str) -> List[str]: + """gets list of files associated with one table""" + table_dir = self.get_table_dir(table_name) + # we need the table prefix so we separate table files if in the same folder + table_prefix = self.get_table_prefix(table_name) + return self.list_files_with_prefixes(table_dir, [table_prefix]) + + def list_files_with_prefixes(self, table_dir: str, prefixes: List[str]) -> List[str]: + """returns all files in a directory that match given prefixes""" + result = [] + for current_dir, _dirs, files in self.fs_client.walk(table_dir, detail=False, refresh=True): + for file in files: + filename = posixpath.join(current_dir, file) + for p in prefixes: + if filename.startswith(p): + result.append(posixpath.join(current_dir, file)) + continue + return result def is_storage_initialized(self) -> bool: return self.fs_client.exists(posixpath.join(self.dataset_path, INIT_FILE_NAME)) # type: ignore[no-any-return] @@ -274,10 +281,11 @@ def _to_path_safe_string(self, s: str) -> str: """for base64 strings""" return base64.b64decode(s).hex() if s else None - def _list_dlt_dir(self, dirname: str) -> Iterator[Tuple[str, List[str]]]: + def _list_dlt_table_files(self, table_name: str) -> Iterator[Tuple[str, List[str]]]: + dirname = self.get_table_dir(table_name) if not self.fs_client.exists(posixpath.join(dirname, INIT_FILE_NAME)): raise DestinationUndefinedEntity({"dir": dirname}) - for filepath in self.fs_client.ls(dirname, detail=False, refresh=True): + for filepath in self.list_table_files(table_name): filename = os.path.splitext(os.path.basename(filepath))[0] fileparts = filename.split(FILENAME_SEPARATOR) if len(fileparts) != 3: @@ -313,8 +321,7 @@ def complete_load(self, load_id: str) -> None: def _get_state_file_name(self, pipeline_name: str, version_hash: str, load_id: str) -> str: """gets full path for schema file for a given hash""" return posixpath.join( - self.dataset_path, - self.schema.state_table_name, + self.get_table_dir(self.schema.state_table_name), f"{pipeline_name}{FILENAME_SEPARATOR}{load_id}{FILENAME_SEPARATOR}{self._to_path_safe_string(version_hash)}.jsonl", ) @@ -340,13 +347,10 @@ def _store_current_state(self, load_id: str) -> None: self._write_to_json_file(hash_path, cast(DictStrAny, pipeline_state_doc)) def get_stored_state(self, pipeline_name: str) -> Optional[StateInfo]: - # get base dir - dirname = posixpath.dirname(self._get_state_file_name(pipeline_name, "", "")) - # search newest state selected_path = None newest_load_id = "0" - for filepath, fileparts in self._list_dlt_dir(dirname): + for filepath, fileparts in self._list_dlt_table_files(self.schema.state_table_name): if fileparts[0] == pipeline_name and fileparts[1] > newest_load_id: newest_load_id = fileparts[1] selected_path = filepath @@ -365,9 +369,9 @@ def get_stored_state(self, pipeline_name: str) -> Optional[StateInfo]: def _get_schema_file_name(self, version_hash: str, load_id: str) -> str: """gets full path for schema file for a given hash""" + return posixpath.join( - self.dataset_path, - self.schema.version_table_name, + self.get_table_dir(self.schema.version_table_name), f"{self.schema.name}{FILENAME_SEPARATOR}{load_id}{FILENAME_SEPARATOR}{self._to_path_safe_string(version_hash)}.jsonl", ) @@ -376,11 +380,10 @@ def _get_stored_schema_by_hash_or_newest( ) -> Optional[StorageSchemaInfo]: """Get the schema by supplied hash, falls back to getting the newest version matching the existing schema name""" version_hash = self._to_path_safe_string(version_hash) - dirname = posixpath.dirname(self._get_schema_file_name("", "")) # find newest schema for pipeline or by version hash selected_path = None newest_load_id = "0" - for filepath, fileparts in self._list_dlt_dir(dirname): + for filepath, fileparts in self._list_dlt_table_files(self.schema.version_table_name): if ( not version_hash and fileparts[0] == self.schema.name diff --git a/dlt/pipeline/exceptions.py b/dlt/pipeline/exceptions.py index d3538a8377..289de92782 100644 --- a/dlt/pipeline/exceptions.py +++ b/dlt/pipeline/exceptions.py @@ -47,6 +47,15 @@ def __init__(self, pipeline_name: str, destination_name: str) -> None: ) +class FSClientNotAvailable(PipelineException): + def __init__(self, pipeline_name: str, destination_name: str) -> None: + super().__init__( + pipeline_name, + f"Filesystem Client not available for destination {destination_name} in pipeline" + f" {pipeline_name}", + ) + + class PipelineStepFailed(PipelineException): """Raised by run, extract, normalize and load Pipeline methods.""" diff --git a/dlt/pipeline/pipeline.py b/dlt/pipeline/pipeline.py index e1821a9ac8..748c71ad5a 100644 --- a/dlt/pipeline/pipeline.py +++ b/dlt/pipeline/pipeline.py @@ -107,6 +107,7 @@ from dlt.normalize import Normalize from dlt.normalize.configuration import NormalizeConfiguration from dlt.destinations.sql_client import SqlClientBase +from dlt.destinations.fs_client import FSClientBase from dlt.destinations.job_client_impl import SqlJobClientBase from dlt.load.configuration import LoaderConfiguration from dlt.load import Load @@ -120,6 +121,7 @@ PipelineNotActive, PipelineStepFailed, SqlClientNotAvailable, + FSClientNotAvailable, ) from dlt.pipeline.trace import ( PipelineTrace, @@ -956,6 +958,23 @@ def sql_client(self, schema_name: str = None, credentials: Any = None) -> SqlCli schema = self._get_schema_or_create(schema_name) return self._sql_job_client(schema, credentials).sql_client + def _fs_client(self, schema_name: str = None, credentials: Any = None) -> FSClientBase: + """Returns a filesystem client configured to point to the right folder / bucket for each table. + For example you may read all parquet files as bytes for one table with the following code: + >>> files = pipeline._fs_client.list_table_files("customers") + >>> for file in files: + >>> file_bytes = pipeline.fs_client.read_bytes(file) + >>> # now you can read them into a pyarrow table for example + >>> import pyarrow.parquet as pq + >>> table = pq.read_table(io.BytesIO(file_bytes)) + NOTE: This currently is considered a private endpoint and will become stable after we have decided on the + interface of FSClientBase. + """ + client = self.destination_client(schema_name, credentials) + if isinstance(client, FSClientBase): + return client + raise FSClientNotAvailable(self.pipeline_name, self.destination.destination_name) + def destination_client(self, schema_name: str = None, credentials: Any = None) -> JobClientBase: """Get the destination job client for the configured destination Use the client with `with` statement to manage opening and closing connection to the destination: diff --git a/tests/helpers/airflow_tests/test_airflow_wrapper.py b/tests/helpers/airflow_tests/test_airflow_wrapper.py index 84a30f730c..a328403ba0 100644 --- a/tests/helpers/airflow_tests/test_airflow_wrapper.py +++ b/tests/helpers/airflow_tests/test_airflow_wrapper.py @@ -17,7 +17,7 @@ from dlt.helpers.airflow_helper import PipelineTasksGroup, DEFAULT_RETRY_BACKOFF from dlt.pipeline.exceptions import CannotRestorePipelineException, PipelineStepFailed -from tests.load.pipeline.utils import load_table_counts +from tests.pipeline.utils import load_table_counts from tests.utils import TEST_STORAGE_ROOT diff --git a/tests/load/athena_iceberg/test_athena_iceberg.py b/tests/load/athena_iceberg/test_athena_iceberg.py index 0b8ca9c6ff..dbcdc5c23e 100644 --- a/tests/load/athena_iceberg/test_athena_iceberg.py +++ b/tests/load/athena_iceberg/test_athena_iceberg.py @@ -6,9 +6,8 @@ import dlt from dlt.common import pendulum from dlt.common.utils import uniq_id -from tests.load.pipeline.utils import load_table_counts from tests.cases import table_update_and_row, assert_all_data_types_row -from tests.pipeline.utils import assert_load_info +from tests.pipeline.utils import assert_load_info, load_table_counts from tests.load.pipeline.utils import destinations_configs, DestinationTestConfiguration diff --git a/tests/load/duckdb/test_duckdb_client.py b/tests/load/duckdb/test_duckdb_client.py index 6cfb77d613..896cba7d5a 100644 --- a/tests/load/duckdb/test_duckdb_client.py +++ b/tests/load/duckdb/test_duckdb_client.py @@ -14,7 +14,8 @@ ) from dlt.destinations import duckdb -from tests.load.pipeline.utils import drop_pipeline, assert_table +from tests.load.pipeline.utils import drop_pipeline +from tests.pipeline.utils import assert_table from tests.utils import patch_home_dir, autouse_test_storage, preserve_environ, TEST_STORAGE_ROOT # mark all tests as essential, do not remove diff --git a/tests/load/pipeline/test_arrow_loading.py b/tests/load/pipeline/test_arrow_loading.py index b239899bce..2ba633504e 100644 --- a/tests/load/pipeline/test_arrow_loading.py +++ b/tests/load/pipeline/test_arrow_loading.py @@ -12,8 +12,7 @@ from dlt.common.time import reduce_pendulum_datetime_precision from dlt.common.utils import uniq_id from tests.load.utils import destinations_configs, DestinationTestConfiguration -from tests.load.pipeline.utils import select_data -from tests.pipeline.utils import assert_load_info +from tests.pipeline.utils import assert_load_info, select_data from tests.utils import ( TestDataItemFormat, arrow_item_from_pandas, diff --git a/tests/load/pipeline/test_athena.py b/tests/load/pipeline/test_athena.py index 845f9b8a27..0a1118d2b0 100644 --- a/tests/load/pipeline/test_athena.py +++ b/tests/load/pipeline/test_athena.py @@ -5,9 +5,8 @@ import dlt from dlt.common import pendulum from dlt.common.utils import uniq_id -from tests.load.pipeline.utils import load_table_counts from tests.cases import table_update_and_row, assert_all_data_types_row -from tests.pipeline.utils import assert_load_info +from tests.pipeline.utils import assert_load_info, load_table_counts from tests.load.pipeline.utils import destinations_configs, DestinationTestConfiguration diff --git a/tests/load/pipeline/test_dbt_helper.py b/tests/load/pipeline/test_dbt_helper.py index 91318d0f34..38e66c4ab9 100644 --- a/tests/load/pipeline/test_dbt_helper.py +++ b/tests/load/pipeline/test_dbt_helper.py @@ -10,7 +10,7 @@ from dlt.helpers.dbt import create_venv from dlt.helpers.dbt.exceptions import DBTProcessingError, PrerequisitesException -from tests.load.pipeline.utils import select_data +from tests.pipeline.utils import select_data from tests.utils import ACTIVE_SQL_DESTINATIONS from tests.load.pipeline.utils import destinations_configs, DestinationTestConfiguration diff --git a/tests/load/pipeline/test_dremio.py b/tests/load/pipeline/test_dremio.py index 9f4fd75c93..9a4c96c922 100644 --- a/tests/load/pipeline/test_dremio.py +++ b/tests/load/pipeline/test_dremio.py @@ -2,7 +2,7 @@ from typing import Iterator, Any import dlt -from tests.load.pipeline.utils import load_table_counts +from tests.pipeline.utils import load_table_counts from tests.load.utils import DestinationTestConfiguration, destinations_configs diff --git a/tests/load/pipeline/test_duckdb.py b/tests/load/pipeline/test_duckdb.py index d5bd5c13a0..3f9821cee0 100644 --- a/tests/load/pipeline/test_duckdb.py +++ b/tests/load/pipeline/test_duckdb.py @@ -6,11 +6,10 @@ from dlt.pipeline.exceptions import PipelineStepFailed from tests.cases import TABLE_UPDATE_ALL_INT_PRECISIONS, TABLE_UPDATE_ALL_TIMESTAMP_PRECISIONS -from tests.pipeline.utils import airtable_emojis +from tests.pipeline.utils import airtable_emojis, load_table_counts from tests.load.pipeline.utils import ( destinations_configs, DestinationTestConfiguration, - load_table_counts, ) diff --git a/tests/load/pipeline/test_filesystem_pipeline.py b/tests/load/pipeline/test_filesystem_pipeline.py index 28c17e14dd..c56356205c 100644 --- a/tests/load/pipeline/test_filesystem_pipeline.py +++ b/tests/load/pipeline/test_filesystem_pipeline.py @@ -6,6 +6,7 @@ import dlt import pytest +from dlt.common import json from dlt.common import pendulum from dlt.common.storages.load_package import ParsedLoadJobFileName from dlt.common.utils import uniq_id @@ -20,9 +21,10 @@ from tests.load.pipeline.utils import ( destinations_configs, DestinationTestConfiguration, - load_table_counts, ) +from tests.pipeline.utils import load_table_counts + skip_if_not_active("filesystem") @@ -258,6 +260,9 @@ def count(*args, **kwargs) -> Any: "hiphip": counter("Hurraaaa"), } now = pendulum.now() + os.environ["DESTINATION__FILESYSTEM__BUCKET_URL"] = "file://_storage" + os.environ["DATA_WRITER__DISABLE_COMPRESSION"] = "TRUE" + fs_destination = filesystem( layout=layout, extra_placeholders=extra_placeholders, @@ -268,7 +273,11 @@ def count(*args, **kwargs) -> Any: destination=fs_destination, ) load_info = pipeline.run( - dlt.resource(data, name="simple_rows"), + [ + dlt.resource(data, name="table_1"), + dlt.resource(data * 2, name="table_2"), + dlt.resource(data * 3, name="table_3"), + ], write_disposition="append", ) client = pipeline.destination_client() @@ -310,6 +319,18 @@ def count(*args, **kwargs) -> Any: # and in this test scenario we have 3 callbacks assert call_count >= 6 + # check that table separation works for every path + # we cannot test when ext is not the last value + if ".{ext}{timestamp}" not in layout: + assert load_table_counts(pipeline, "table_1", "table_2", "table_3") == { + "table_1": 2, + "table_2": 4, + "table_3": 6, + } + pipeline._fs_client().truncate_tables(["table_1", "table_3"]) + if ".{ext}{timestamp}" not in layout: + assert load_table_counts(pipeline, "table_1", "table_2", "table_3") == {"table_2": 4} + @pytest.mark.parametrize( "destination_config", @@ -423,11 +444,21 @@ def test_knows_dataset_state(destination_config: DestinationTestConfiguration) - ids=lambda x: x.name, ) @pytest.mark.parametrize("restore", [True, False]) -def test_simple_incremental( +@pytest.mark.parametrize( + "layout", + [ + "{table_name}/{load_id}.{file_id}.{ext}", + "{schema_name}/other_folder/{table_name}-{load_id}.{file_id}.{ext}", + "{table_name}/{load_package_timestamp}/{d}/{load_id}.{file_id}.{ext}", + ], +) # we need a layout where the table has its own folder and one where it does not +def test_state_with_simple_incremental( destination_config: DestinationTestConfiguration, restore: bool, + layout: str, ) -> None: os.environ["RESTORE_FROM_DESTINATION"] = str(restore) + os.environ["DESTINATION__FILESYSTEM__LAYOUT"] = layout p = destination_config.setup_pipeline("p1", dataset_name="incremental_test") @@ -450,7 +481,87 @@ def my_resource_inc(prim_key=dlt.sources.incremental("id")): p.run(my_resource) p._wipe_working_folder() + # check incremental p = destination_config.setup_pipeline("p1", dataset_name="incremental_test") p.run(my_resource_inc) - assert load_table_counts(p, "items") == {"items": 4 if restore else 6} + + +@pytest.mark.parametrize( + "destination_config", + destinations_configs(all_buckets_filesystem_configs=True), + ids=lambda x: x.name, +) +@pytest.mark.parametrize( + "layout", + [ + "{table_name}/{load_id}.{file_id}.{ext}", + "{schema_name}/other_folder/{table_name}-{load_id}.{file_id}.{ext}", + ], +) # we need a layout where the table has its own folder and one where it does not +def test_client_methods( + destination_config: DestinationTestConfiguration, + layout: str, +) -> None: + p = destination_config.setup_pipeline("access", dataset_name="incremental_test") + os.environ["DESTINATION__FILESYSTEM__LAYOUT"] = layout + + @dlt.resource() + def table_1(): + yield [1, 2, 3, 4, 5] + + @dlt.resource() + def table_2(): + yield [1, 2, 3, 4, 5, 6, 7] + + @dlt.resource() + def table_3(): + yield [1, 2, 3, 4, 5, 6, 7, 8] + + # 3 files for t_1, 2 files for t_2 + p.run([table_1(), table_2()]) + p.run([table_1(), table_2()]) + p.run([table_1()]) + + fs_client = p._fs_client() + t1_files = fs_client.list_table_files("table_1") + t2_files = fs_client.list_table_files("table_2") + assert len(t1_files) == 3 + assert len(t2_files) == 2 + + assert load_table_counts(p, "table_1", "table_2") == {"table_1": 15, "table_2": 14} + + # verify that files are in the same folder on the second layout + folder = fs_client.get_table_dir("table_1") + file_count = len(fs_client.fs_client.ls(folder)) + if "{table_name}/" in layout: + print(fs_client.fs_client.ls(folder)) + assert file_count == 3 + else: + assert file_count == 5 + + # check opening of file + values = [] + for line in fs_client.read_text(t1_files[0]).split("\n"): + if line: + values.append(json.loads(line)["value"]) + assert values == [1, 2, 3, 4, 5] + + # check binary read + assert fs_client.read_bytes(t1_files[0]) == str.encode(fs_client.read_text(t1_files[0])) + + # check truncate + fs_client.truncate_tables(["table_1"]) + assert load_table_counts(p, "table_1", "table_2") == {"table_2": 14} + + # load again + p.run([table_1(), table_2(), table_3()]) + assert load_table_counts(p, "table_1", "table_2", "table_3") == { + "table_1": 5, + "table_2": 21, + "table_3": 8, + } + + # test truncate multiple + fs_client.truncate_tables(["table_1", "table_3"]) + assert load_table_counts(p, "table_1", "table_2", "table_3") == {"table_2": 21} diff --git a/tests/load/pipeline/test_merge_disposition.py b/tests/load/pipeline/test_merge_disposition.py index 2924aeb6df..8fa018c6c3 100644 --- a/tests/load/pipeline/test_merge_disposition.py +++ b/tests/load/pipeline/test_merge_disposition.py @@ -18,8 +18,7 @@ from dlt.sources.helpers.transform import skip_first, take_first from dlt.pipeline.exceptions import PipelineStepFailed -from tests.pipeline.utils import assert_load_info -from tests.load.pipeline.utils import load_table_counts, select_data +from tests.pipeline.utils import assert_load_info, load_table_counts, select_data from tests.load.pipeline.utils import destinations_configs, DestinationTestConfiguration # uncomment add motherduck tests diff --git a/tests/load/pipeline/test_pipelines.py b/tests/load/pipeline/test_pipelines.py index d362bab018..03d7819c73 100644 --- a/tests/load/pipeline/test_pipelines.py +++ b/tests/load/pipeline/test_pipelines.py @@ -26,7 +26,14 @@ ) from tests.utils import TEST_STORAGE_ROOT, data_to_item_format, preserve_environ -from tests.pipeline.utils import assert_data_table_counts, assert_load_info +from tests.pipeline.utils import ( + assert_data_table_counts, + assert_load_info, + assert_query_data, + assert_table, + load_table_counts, + select_data, +) from tests.load.utils import ( TABLE_ROW_ALL_DATA_TYPES, TABLE_UPDATE_COLUMNS_SCHEMA, @@ -35,10 +42,6 @@ ) from tests.load.pipeline.utils import ( drop_active_pipeline_data, - assert_query_data, - assert_table, - load_table_counts, - select_data, REPLACE_STRATEGIES, ) from tests.load.pipeline.utils import destinations_configs, DestinationTestConfiguration diff --git a/tests/load/pipeline/test_replace_disposition.py b/tests/load/pipeline/test_replace_disposition.py index 09a746433f..f3b58aa5f6 100644 --- a/tests/load/pipeline/test_replace_disposition.py +++ b/tests/load/pipeline/test_replace_disposition.py @@ -3,13 +3,9 @@ import dlt, os, pytest from dlt.common.utils import uniq_id -from tests.pipeline.utils import assert_load_info +from tests.pipeline.utils import assert_load_info, load_table_counts, load_tables_to_dicts from tests.load.pipeline.utils import ( drop_active_pipeline_data, - load_table_counts, - load_tables_to_dicts, -) -from tests.load.pipeline.utils import ( destinations_configs, DestinationTestConfiguration, REPLACE_STRATEGIES, diff --git a/tests/load/pipeline/test_restore_state.py b/tests/load/pipeline/test_restore_state.py index 6518ca46ae..a2d00001c2 100644 --- a/tests/load/pipeline/test_restore_state.py +++ b/tests/load/pipeline/test_restore_state.py @@ -24,7 +24,8 @@ from tests.cases import JSON_TYPED_DICT, JSON_TYPED_DICT_DECODED from tests.common.utils import IMPORTED_VERSION_HASH_ETH_V9, yml_case_path as common_yml_case_path from tests.common.configuration.utils import environment -from tests.load.pipeline.utils import assert_query_data, drop_active_pipeline_data +from tests.load.pipeline.utils import drop_active_pipeline_data +from tests.pipeline.utils import assert_query_data from tests.load.utils import ( destinations_configs, DestinationTestConfiguration, diff --git a/tests/load/pipeline/test_scd2.py b/tests/load/pipeline/test_scd2.py index 117f834778..8ea39d31e5 100644 --- a/tests/load/pipeline/test_scd2.py +++ b/tests/load/pipeline/test_scd2.py @@ -21,8 +21,9 @@ from tests.load.pipeline.utils import ( destinations_configs, DestinationTestConfiguration, - load_tables_to_dicts, ) +from tests.pipeline.utils import load_tables_to_dicts + from tests.utils import TPythonTableFormat get_row_hash = DataItemNormalizer.get_row_hash diff --git a/tests/load/pipeline/test_stage_loading.py b/tests/load/pipeline/test_stage_loading.py index 4d289a5384..3c0207c9bc 100644 --- a/tests/load/pipeline/test_stage_loading.py +++ b/tests/load/pipeline/test_stage_loading.py @@ -8,7 +8,7 @@ from dlt.common.schema.typing import TDataType from tests.load.pipeline.test_merge_disposition import github -from tests.load.pipeline.utils import load_table_counts +from tests.pipeline.utils import load_table_counts from tests.pipeline.utils import assert_load_info from tests.load.utils import ( TABLE_ROW_ALL_DATA_TYPES, diff --git a/tests/load/pipeline/test_write_disposition_changes.py b/tests/load/pipeline/test_write_disposition_changes.py index 2a7a94ef6b..0af6263fb6 100644 --- a/tests/load/pipeline/test_write_disposition_changes.py +++ b/tests/load/pipeline/test_write_disposition_changes.py @@ -4,8 +4,10 @@ from tests.load.pipeline.utils import ( destinations_configs, DestinationTestConfiguration, - assert_data_table_counts, ) + +from tests.pipeline.utils import assert_data_table_counts + from tests.pipeline.utils import assert_load_info from dlt.pipeline.exceptions import PipelineStepFailed @@ -97,7 +99,6 @@ def test_switch_to_merge(destination_config: DestinationTestConfiguration, with_ pipeline_name="test_switch_to_merge", full_refresh=True ) - @dlt.source() def source(): return data_with_subtables(10) diff --git a/tests/load/pipeline/utils.py b/tests/load/pipeline/utils.py index 7a5ef02ae6..c30669ed0d 100644 --- a/tests/load/pipeline/utils.py +++ b/tests/load/pipeline/utils.py @@ -7,15 +7,6 @@ from dlt.common.configuration.container import Container from dlt.common.pipeline import LoadInfo, PipelineContext -from tests.pipeline.utils import ( - load_table_counts, - load_data_table_counts, - assert_data_table_counts, - load_file, - load_files, - load_tables_to_dicts, - load_table_distinct_counts, -) from tests.load.utils import DestinationTestConfiguration, destinations_configs if TYPE_CHECKING: @@ -68,93 +59,3 @@ def _drop_dataset(schema_name: str) -> None: # p._wipe_working_folder() # deactivate context Container()[PipelineContext].deactivate() - - -def _is_filesystem(p: dlt.Pipeline) -> bool: - if not p.destination: - return False - return p.destination.destination_name == "filesystem" - - -def assert_table( - p: dlt.Pipeline, - table_name: str, - table_data: List[Any], - schema_name: str = None, - info: LoadInfo = None, -) -> None: - func = _assert_table_fs if _is_filesystem(p) else _assert_table_sql - func(p, table_name, table_data, schema_name, info) - - -def _assert_table_sql( - p: dlt.Pipeline, - table_name: str, - table_data: List[Any], - schema_name: str = None, - info: LoadInfo = None, -) -> None: - with p.sql_client(schema_name=schema_name) as c: - table_name = c.make_qualified_table_name(table_name) - # Implement NULLS FIRST sort in python - assert_query_data( - p, - f"SELECT * FROM {table_name} ORDER BY 1", - table_data, - schema_name, - info, - sort_key=lambda row: row[0] is not None, - ) - - -def _assert_table_fs( - p: dlt.Pipeline, - table_name: str, - table_data: List[Any], - schema_name: str = None, - info: LoadInfo = None, -) -> None: - """Assert table is loaded to filesystem destination""" - client: FilesystemClient = p.destination_client(schema_name) # type: ignore[assignment] - # get table directory - table_dir = list(client._get_table_dirs([table_name]))[0] - # assumes that each table has a folder - files = client.fs_client.ls(table_dir, detail=False, refresh=True) - # glob = client.fs_client.glob(posixpath.join(client.dataset_path, f'{client.table_prefix_layout.format(schema_name=schema_name, table_name=table_name)}/*')) - assert len(files) >= 1 - assert client.fs_client.isfile(files[0]) - # TODO: may verify that filesize matches load package size - assert client.fs_client.size(files[0]) > 0 - - -def select_data(p: dlt.Pipeline, sql: str, schema_name: str = None) -> List[Sequence[Any]]: - with p.sql_client(schema_name=schema_name) as c: - with c.execute_query(sql) as cur: - return list(cur.fetchall()) - - -def assert_query_data( - p: dlt.Pipeline, - sql: str, - table_data: List[Any], - schema_name: str = None, - info: LoadInfo = None, - sort_key: Callable[[Any], Any] = None, -) -> None: - """Asserts that query selecting single column of values matches `table_data`. If `info` is provided, second column must contain one of load_ids in `info` - - Args: - sort_key: Optional sort key function to sort the query result before comparing - - """ - rows = select_data(p, sql, schema_name) - assert len(rows) == len(table_data) - if sort_key is not None: - rows = sorted(rows, key=sort_key) - for row, d in zip(rows, table_data): - row = list(row) - # first element comes from the data - assert row[0] == d - # the second is load id - if info: - assert row[1] in info.loads_ids diff --git a/tests/pipeline/test_schema_contracts.py b/tests/pipeline/test_schema_contracts.py index 579a6289cf..7eafb1ea24 100644 --- a/tests/pipeline/test_schema_contracts.py +++ b/tests/pipeline/test_schema_contracts.py @@ -11,7 +11,7 @@ from dlt.pipeline.exceptions import PipelineStepFailed from dlt.extract.exceptions import ResourceExtractionError -from tests.load.pipeline.utils import load_table_counts +from tests.pipeline.utils import load_table_counts from tests.utils import ( TestDataItemFormat, skip_if_not_active, diff --git a/tests/pipeline/utils.py b/tests/pipeline/utils.py index 569ab69bfc..072a12782c 100644 --- a/tests/pipeline/utils.py +++ b/tests/pipeline/utils.py @@ -1,5 +1,5 @@ import posixpath -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Tuple, Callable, Sequence import pytest import random from os import environ @@ -11,8 +11,10 @@ from dlt.common.schema.typing import LOADS_TABLE_NAME from dlt.common.typing import DictStrAny from dlt.destinations.impl.filesystem.filesystem import FilesystemClient +from dlt.destinations.fs_client import FSClientBase from dlt.pipeline.exceptions import SqlClientNotAvailable from dlt.common.storages import FileStorage + from tests.utils import TEST_STORAGE_ROOT PIPELINE_TEST_CASES_PATH = "./tests/pipeline/cases/" @@ -24,15 +26,6 @@ def drop_dataset_from_env() -> None: del environ["DATASET_NAME"] -def assert_load_info(info: LoadInfo, expected_load_packages: int = 1) -> None: - """Asserts that expected number of packages was loaded and there are no failed jobs""" - assert len(info.loads_ids) == expected_load_packages - # all packages loaded - assert all(p.completed_at is not None for p in info.load_packages) is True - # no failed jobs in any of the packages - assert all(len(p.jobs["failed_jobs"]) == 0 for p in info.load_packages) is True - - def json_case_path(name: str) -> str: return f"{PIPELINE_TEST_CASES_PATH}{name}.json" @@ -42,46 +35,71 @@ def load_json_case(name: str) -> DictStrAny: return json.load(f) -def load_table_counts(p: dlt.Pipeline, *table_names: str) -> DictStrAny: - """Returns row counts for `table_names` as dict""" +@dlt.source +def airtable_emojis(): + @dlt.resource(name="📆 Schedule") + def schedule(): + yield [1, 2, 3] - # try sql, could be other destination though - try: - with p.sql_client() as c: - qualified_names = [c.make_qualified_table_name(name) for name in table_names] - query = "\nUNION ALL\n".join( - [ - f"SELECT '{name}' as name, COUNT(1) as c FROM {q_name}" - for name, q_name in zip(table_names, qualified_names) - ] - ) - with c.execute_query(query) as cur: - rows = list(cur.fetchall()) - return {r[0]: r[1] for r in rows} - except SqlClientNotAvailable: - pass + @dlt.resource(name="💰Budget", primary_key=("🔑book_id", "asset_id")) + def budget(): + # return empty + yield - # try filesystem - file_tables = load_files(p, *table_names) - result = {} - for table_name, items in file_tables.items(): - result[table_name] = len(items) - return result + @dlt.resource(name="🦚Peacock", selected=False, primary_key="🔑id") + def peacock(): + dlt.current.resource_state()["🦚🦚🦚"] = "🦚" + yield [{"peacock": [1, 2, 3], "🔑id": 1}] + @dlt.resource(name="🦚WidePeacock", selected=False) + def wide_peacock(): + yield [{"peacock": [1, 2, 3]}] -def load_data_table_counts(p: dlt.Pipeline) -> DictStrAny: - tables = [table["name"] for table in p.default_schema.data_tables()] - return load_table_counts(p, *tables) + return budget, schedule, peacock, wide_peacock -def assert_data_table_counts(p: dlt.Pipeline, expected_counts: DictStrAny) -> None: - table_counts = load_data_table_counts(p) - assert ( - table_counts == expected_counts - ), f"Table counts do not match, expected {expected_counts}, got {table_counts}" +def run_deferred(iters): + @dlt.defer + def item(n): + sleep(random.random() / 2) + return n + + for n in range(iters): + yield item(n) + + +@dlt.source +def many_delayed(many, iters): + for n in range(many): + yield dlt.resource(run_deferred(iters), name="resource_" + str(n)) + + +# +# Utils for accessing data in pipelines +# + + +def assert_load_info(info: LoadInfo, expected_load_packages: int = 1) -> None: + """Asserts that expected number of packages was loaded and there are no failed jobs""" + assert len(info.loads_ids) == expected_load_packages + # all packages loaded + assert all(p.completed_at is not None for p in info.load_packages) is True + # no failed jobs in any of the packages + assert all(len(p.jobs["failed_jobs"]) == 0 for p in info.load_packages) is True + + +# +# Load utils +# -def load_file(fs_client, path: str, file: str) -> Tuple[str, List[Dict[str, Any]]]: +def _is_filesystem(p: dlt.Pipeline) -> bool: + if not p.destination: + return False + return p.destination.destination_name == "filesystem" + + +def _load_file(client: FSClientBase, filepath) -> List[Dict[str, Any]]: """ util function to load a filesystem destination file and return parsed content values may not be cast to the right type, especially for insert_values, please @@ -90,25 +108,21 @@ def load_file(fs_client, path: str, file: str) -> Tuple[str, List[Dict[str, Any] result: List[Dict[str, Any]] = [] # check if this is a file we want to read - file_name_items = file.split(".") + file_name_items = filepath.split(".") ext = file_name_items[-1] if ext not in ["jsonl", "insert_values", "parquet"]: - return "skip", [] - - # table name will be last element of path - table_name = path.split("/")[-1] - full_path = posixpath.join(path, file) + return [] # load jsonl if ext == "jsonl": - file_text = fs_client.read_text(full_path) + file_text = client.read_text(filepath) for line in file_text.split("\n"): if line: result.append(json.loads(line)) # load insert_values (this is a bit volatile if the exact format of the source file changes) elif ext == "insert_values": - file_text = fs_client.read_text(full_path) + file_text = client.read_text(filepath) lines = file_text.split("\n") cols = lines[0][15:-2].split(",") for line in lines[2:]: @@ -120,7 +134,7 @@ def load_file(fs_client, path: str, file: str) -> Tuple[str, List[Dict[str, Any] elif ext == "parquet": import pyarrow.parquet as pq - file_bytes = fs_client.read_bytes(full_path) + file_bytes = client.read_bytes(filepath) table = pq.read_table(io.BytesIO(file_bytes)) cols = table.column_names count = 0 @@ -135,104 +149,178 @@ def load_file(fs_client, path: str, file: str) -> Tuple[str, List[Dict[str, Any] item_count += 1 count += 1 - return table_name, result + return result -def load_files(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[Dict[str, Any]]]: +# +# Load table dicts +# +def _load_tables_to_dicts_fs(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[Dict[str, Any]]]: """For now this will expect the standard layout in the filesystem destination, if changed the results will not be correct""" - client: FilesystemClient = p.destination_client() # type: ignore[assignment] + client = p._fs_client() result: Dict[str, Any] = {} - for basedir, _dirs, files in client.fs_client.walk( - client.dataset_path, detail=False, refresh=True - ): - for file in files: - table_name, items = load_file(client.fs_client, basedir, file) - if table_name not in table_names: - continue + + for table_name in table_names: + table_files = client.list_table_files(table_name) + for file in table_files: + items = _load_file(client, file) if table_name in result: result[table_name] = result[table_name] + items else: result[table_name] = items + return result + + +def _load_tables_to_dicts_sql( + p: dlt.Pipeline, *table_names: str +) -> Dict[str, List[Dict[str, Any]]]: + result = {} + for table_name in table_names: + table_rows = [] + columns = p.default_schema.get_table_columns(table_name).keys() + query_columns = ",".join(map(p.sql_client().capabilities.escape_identifier, columns)) + with p.sql_client() as c: + query_columns = ",".join(map(c.escape_column_name, columns)) + f_q_table_name = c.make_qualified_table_name(table_name) + query = f"SELECT {query_columns} FROM {f_q_table_name}" + with c.execute_query(query) as cur: + for row in list(cur.fetchall()): + table_rows.append(dict(zip(columns, row))) + result[table_name] = table_rows return result def load_tables_to_dicts(p: dlt.Pipeline, *table_names: str) -> Dict[str, List[Dict[str, Any]]]: - # try sql, could be other destination though - try: - result = {} - for table_name in table_names: - table_rows = [] - columns = p.default_schema.get_table_columns(table_name).keys() - query_columns = ",".join(map(p.sql_client().capabilities.escape_identifier, columns)) - - with p.sql_client() as c: - query_columns = ",".join(map(c.escape_column_name, columns)) - f_q_table_name = c.make_qualified_table_name(table_name) - query = f"SELECT {query_columns} FROM {f_q_table_name}" - with c.execute_query(query) as cur: - for row in list(cur.fetchall()): - table_rows.append(dict(zip(columns, row))) - result[table_name] = table_rows - return result - - except SqlClientNotAvailable: - pass - - # try files - return load_files(p, *table_names) - - -def load_table_distinct_counts( - p: dlt.Pipeline, distinct_column: str, *table_names: str -) -> DictStrAny: - """Returns counts of distinct values for column `distinct_column` for `table_names` as dict""" - query = "\nUNION ALL\n".join( - [ - f"SELECT '{name}' as name, COUNT(DISTINCT {distinct_column}) as c FROM {name}" - for name in table_names - ] - ) + func = _load_tables_to_dicts_fs if _is_filesystem(p) else _load_tables_to_dicts_sql + return func(p, *table_names) + + +# +# Load table counts +# +def _load_table_counts_fs(p: dlt.Pipeline, *table_names: str) -> DictStrAny: + file_tables = _load_tables_to_dicts_fs(p, *table_names) + result = {} + for table_name, items in file_tables.items(): + result[table_name] = len(items) + return result + + +def _load_table_counts_sql(p: dlt.Pipeline, *table_names: str) -> DictStrAny: with p.sql_client() as c: + qualified_names = [c.make_qualified_table_name(name) for name in table_names] + query = "\nUNION ALL\n".join( + [ + f"SELECT '{name}' as name, COUNT(1) as c FROM {q_name}" + for name, q_name in zip(table_names, qualified_names) + ] + ) with c.execute_query(query) as cur: rows = list(cur.fetchall()) return {r[0]: r[1] for r in rows} -@dlt.source -def airtable_emojis(): - @dlt.resource(name="📆 Schedule") - def schedule(): - yield [1, 2, 3] +def load_table_counts(p: dlt.Pipeline, *table_names: str) -> DictStrAny: + """Returns row counts for `table_names` as dict""" + func = _load_table_counts_fs if _is_filesystem(p) else _load_table_counts_sql + return func(p, *table_names) - @dlt.resource(name="💰Budget", primary_key=("🔑book_id", "asset_id")) - def budget(): - # return empty - yield - @dlt.resource(name="🦚Peacock", selected=False, primary_key="🔑id") - def peacock(): - dlt.current.resource_state()["🦚🦚🦚"] = "🦚" - yield [{"peacock": [1, 2, 3], "🔑id": 1}] +def load_data_table_counts(p: dlt.Pipeline) -> DictStrAny: + tables = [table["name"] for table in p.default_schema.data_tables()] + return load_table_counts(p, *tables) - @dlt.resource(name="🦚WidePeacock", selected=False) - def wide_peacock(): - yield [{"peacock": [1, 2, 3]}] - return budget, schedule, peacock, wide_peacock +def assert_data_table_counts(p: dlt.Pipeline, expected_counts: DictStrAny) -> None: + table_counts = load_data_table_counts(p) + assert ( + table_counts == expected_counts + ), f"Table counts do not match, expected {expected_counts}, got {table_counts}" -def run_deferred(iters): - @dlt.defer - def item(n): - sleep(random.random() / 2) - return n +# +# TODO: migrate to be able to do full assertions on filesystem too, should be possible +# + + +def _assert_table_sql( + p: dlt.Pipeline, + table_name: str, + table_data: List[Any], + schema_name: str = None, + info: LoadInfo = None, +) -> None: + with p.sql_client(schema_name=schema_name) as c: + table_name = c.make_qualified_table_name(table_name) + # Implement NULLS FIRST sort in python + assert_query_data( + p, + f"SELECT * FROM {table_name} ORDER BY 1", + table_data, + schema_name, + info, + sort_key=lambda row: row[0] is not None, + ) - for n in range(iters): - yield item(n) +def _assert_table_fs( + p: dlt.Pipeline, + table_name: str, + table_data: List[Any], + schema_name: str = None, + info: LoadInfo = None, +) -> None: + """Assert table is loaded to filesystem destination""" + client = p._fs_client() + # assumes that each table has a folder + files = client.list_table_files(table_name) + # glob = client.fs_client.glob(posixpath.join(client.dataset_path, f'{client.table_prefix_layout.format(schema_name=schema_name, table_name=table_name)}/*')) + assert len(files) >= 1 + assert client.fs_client.isfile(files[0]) + # TODO: may verify that filesize matches load package size + assert client.fs_client.size(files[0]) > 0 + + +def assert_table( + p: dlt.Pipeline, + table_name: str, + table_data: List[Any], + schema_name: str = None, + info: LoadInfo = None, +) -> None: + func = _assert_table_fs if _is_filesystem(p) else _assert_table_sql + func(p, table_name, table_data, schema_name, info) + + +def select_data(p: dlt.Pipeline, sql: str, schema_name: str = None) -> List[Sequence[Any]]: + with p.sql_client(schema_name=schema_name) as c: + with c.execute_query(sql) as cur: + return list(cur.fetchall()) + + +def assert_query_data( + p: dlt.Pipeline, + sql: str, + table_data: List[Any], + schema_name: str = None, + info: LoadInfo = None, + sort_key: Callable[[Any], Any] = None, +) -> None: + """Asserts that query selecting single column of values matches `table_data`. If `info` is provided, second column must contain one of load_ids in `info` + + Args: + sort_key: Optional sort key function to sort the query result before comparing -@dlt.source -def many_delayed(many, iters): - for n in range(many): - yield dlt.resource(run_deferred(iters), name="resource_" + str(n)) + """ + rows = select_data(p, sql, schema_name) + assert len(rows) == len(table_data) + if sort_key is not None: + rows = sorted(rows, key=sort_key) + for row, d in zip(rows, table_data): + row = list(row) + # first element comes from the data + assert row[0] == d + # the second is load id + if info: + assert row[1] in info.loads_ids From 10b9b47107af01b0079e7a897fc097f943e4adb4 Mon Sep 17 00:00:00 2001 From: Marcin Rudolf Date: Wed, 24 Apr 2024 21:51:31 +0200 Subject: [PATCH 41/41] bumps to version 0.4.9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1e25c6cd71..fbac23d7ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dlt" -version = "0.4.9a2" +version = "0.4.9" description = "dlt is an open-source python-first scalable data loading library that does not require any backend to run." authors = ["dltHub Inc. "] maintainers = [ "Marcin Rudolf ", "Adrian Brudaru ", "Ty Dunn "]