hotdata-dlt-destination 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hotdata_dlt_destination/__init__.py +3 -0
- hotdata_dlt_destination/cli.py +13 -0
- hotdata_dlt_destination/config.py +40 -0
- hotdata_dlt_destination/contracts.py +66 -0
- hotdata_dlt_destination/destination.py +159 -0
- hotdata_dlt_destination/errors.py +31 -0
- hotdata_dlt_destination/hotdata_client.py +137 -0
- hotdata_dlt_destination/idempotency.py +26 -0
- hotdata_dlt_destination/merge.py +70 -0
- hotdata_dlt_destination/parquet.py +17 -0
- hotdata_dlt_destination/pipelines/__init__.py +1 -0
- hotdata_dlt_destination/pipelines/basic_pipeline.py +31 -0
- hotdata_dlt_destination/pipelines/incremental_pipeline.py +39 -0
- hotdata_dlt_destination/pipelines/linear_pipeline.py +116 -0
- hotdata_dlt_destination-0.3.0.dist-info/METADATA +108 -0
- hotdata_dlt_destination-0.3.0.dist-info/RECORD +18 -0
- hotdata_dlt_destination-0.3.0.dist-info/WHEEL +4 -0
- hotdata_dlt_destination-0.3.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hotdata_dlt_destination.config import HotdataDestinationConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
config = HotdataDestinationConfig.from_env()
|
|
8
|
+
print("hotdata-dlt-destination is configured")
|
|
9
|
+
print(f"api_base_url={config.api_base_url}")
|
|
10
|
+
print(f"workspace_id={config.workspace_id}")
|
|
11
|
+
print(f"database_name={config.database_name}")
|
|
12
|
+
print(f"schema={config.schema}")
|
|
13
|
+
print(f"write_disposition={config.write_disposition}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class HotdataDestinationConfig:
|
|
9
|
+
api_key: str
|
|
10
|
+
workspace_id: str
|
|
11
|
+
database_name: str
|
|
12
|
+
api_base_url: str = "https://api.hotdata.dev"
|
|
13
|
+
schema: str = "public"
|
|
14
|
+
write_disposition: str = "append"
|
|
15
|
+
create_database_if_missing: bool = True
|
|
16
|
+
declared_tables: tuple[str, ...] = ()
|
|
17
|
+
max_retries: int = 5
|
|
18
|
+
retry_backoff_seconds: float = 1.0
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_env(cls) -> HotdataDestinationConfig:
|
|
22
|
+
declared = os.environ.get("HOTDATA_DECLARED_TABLES", "")
|
|
23
|
+
declared_tables = tuple(
|
|
24
|
+
table.strip() for table in declared.split(",") if table.strip()
|
|
25
|
+
)
|
|
26
|
+
return cls(
|
|
27
|
+
api_key=os.environ["HOTDATA_API_KEY"],
|
|
28
|
+
workspace_id=os.environ["HOTDATA_WORKSPACE"],
|
|
29
|
+
database_name=os.environ.get("HOTDATA_DATABASE", "dlt"),
|
|
30
|
+
api_base_url=os.environ.get("HOTDATA_API_BASE_URL", "https://api.hotdata.dev"),
|
|
31
|
+
schema=os.environ.get("HOTDATA_SCHEMA", "public"),
|
|
32
|
+
write_disposition=os.environ.get("HOTDATA_WRITE_DISPOSITION", "append"),
|
|
33
|
+
create_database_if_missing=os.environ.get(
|
|
34
|
+
"HOTDATA_CREATE_DATABASE_IF_MISSING", "true"
|
|
35
|
+
).lower()
|
|
36
|
+
in {"1", "true", "yes"},
|
|
37
|
+
declared_tables=declared_tables,
|
|
38
|
+
max_retries=int(os.environ.get("HOTDATA_MAX_RETRIES", "5")),
|
|
39
|
+
retry_backoff_seconds=float(os.environ.get("HOTDATA_RETRY_BACKOFF_SECONDS", "1.0")),
|
|
40
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from dlt.common.schema import TTableSchema
|
|
7
|
+
|
|
8
|
+
IDENTIFIER_RE = re.compile(r"[^a-zA-Z0-9_]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_identifier(value: str) -> str:
|
|
12
|
+
normalized = IDENTIFIER_RE.sub("_", value).strip("_").lower()
|
|
13
|
+
return normalized or "unnamed"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class TableContract:
|
|
18
|
+
database_name: str
|
|
19
|
+
schema: str
|
|
20
|
+
table_name: str
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def qualified_target(self) -> str:
|
|
24
|
+
return f"{self.database_name}.{self.schema}.{self.table_name}"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_table_schema(
|
|
28
|
+
cls,
|
|
29
|
+
table: TTableSchema,
|
|
30
|
+
*,
|
|
31
|
+
database_name: str,
|
|
32
|
+
schema: str,
|
|
33
|
+
) -> TableContract:
|
|
34
|
+
table_name = normalize_identifier(table["name"])
|
|
35
|
+
parent_name = table.get("parent")
|
|
36
|
+
parent_table_name = normalize_identifier(parent_name) if parent_name else ""
|
|
37
|
+
|
|
38
|
+
if parent_table_name:
|
|
39
|
+
normalized_table_name = f"{parent_table_name}__{table_name}"
|
|
40
|
+
else:
|
|
41
|
+
normalized_table_name = table_name
|
|
42
|
+
|
|
43
|
+
return cls(
|
|
44
|
+
database_name=normalize_identifier(database_name),
|
|
45
|
+
schema=normalize_identifier(schema),
|
|
46
|
+
table_name=normalized_table_name,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def declared_table_names(
|
|
51
|
+
cls,
|
|
52
|
+
*,
|
|
53
|
+
database_name: str,
|
|
54
|
+
schema: str,
|
|
55
|
+
table_names: list[str],
|
|
56
|
+
) -> list[str]:
|
|
57
|
+
return sorted(
|
|
58
|
+
{
|
|
59
|
+
cls.from_table_schema(
|
|
60
|
+
{"name": table_name},
|
|
61
|
+
database_name=database_name,
|
|
62
|
+
schema=schema,
|
|
63
|
+
).table_name
|
|
64
|
+
for table_name in table_names
|
|
65
|
+
}
|
|
66
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import dlt
|
|
9
|
+
from dlt.common.destination.exceptions import DestinationTerminalException
|
|
10
|
+
from dlt.common.schema import TTableSchema
|
|
11
|
+
from dlt.common.typing import TDataItems
|
|
12
|
+
|
|
13
|
+
from hotdata_dlt_destination.config import HotdataDestinationConfig
|
|
14
|
+
from hotdata_dlt_destination.contracts import TableContract
|
|
15
|
+
from hotdata_dlt_destination.errors import HotdataTerminalError
|
|
16
|
+
from hotdata_dlt_destination.hotdata_client import HotdataClient
|
|
17
|
+
from hotdata_dlt_destination.idempotency import compute_batch_key, compute_row_key
|
|
18
|
+
from hotdata_dlt_destination.merge import (
|
|
19
|
+
combine_rows,
|
|
20
|
+
resolve_primary_key,
|
|
21
|
+
resolve_write_disposition,
|
|
22
|
+
)
|
|
23
|
+
from hotdata_dlt_destination.parquet import read_parquet_rows, write_rows_parquet
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_batch_rows(items: TDataItems | str) -> list[dict[str, Any]]:
|
|
27
|
+
if isinstance(items, str):
|
|
28
|
+
return read_parquet_rows(items)
|
|
29
|
+
return [dict(item) for item in items]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _augment_rows(
|
|
33
|
+
*,
|
|
34
|
+
table_name: str,
|
|
35
|
+
items: TDataItems | str,
|
|
36
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
37
|
+
rows = _load_batch_rows(items)
|
|
38
|
+
batch_key = compute_batch_key(table_name, rows)
|
|
39
|
+
loaded_at = datetime.now(UTC).isoformat()
|
|
40
|
+
augmented_rows = [
|
|
41
|
+
{
|
|
42
|
+
**row,
|
|
43
|
+
"_hotdata_batch_key": batch_key,
|
|
44
|
+
"_hotdata_row_key": compute_row_key(table_name, row),
|
|
45
|
+
"_hotdata_loaded_at": loaded_at,
|
|
46
|
+
}
|
|
47
|
+
for row in rows
|
|
48
|
+
]
|
|
49
|
+
return batch_key, augmented_rows
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _declared_tables(
|
|
53
|
+
*,
|
|
54
|
+
contract: TableContract,
|
|
55
|
+
declared_tables: list[str] | None,
|
|
56
|
+
) -> list[str]:
|
|
57
|
+
normalized_declared = TableContract.declared_table_names(
|
|
58
|
+
database_name=contract.database_name,
|
|
59
|
+
schema=contract.schema,
|
|
60
|
+
table_names=declared_tables or [],
|
|
61
|
+
)
|
|
62
|
+
return sorted({*normalized_declared, contract.table_name})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dlt.destination(
|
|
66
|
+
batch_size=0,
|
|
67
|
+
loader_file_format="parquet",
|
|
68
|
+
loader_parallelism_strategy="table-sequential",
|
|
69
|
+
name="hotdata",
|
|
70
|
+
naming_convention="direct",
|
|
71
|
+
max_table_nesting=0,
|
|
72
|
+
skip_dlt_columns_and_tables=True,
|
|
73
|
+
)
|
|
74
|
+
def hotdata_destination(
|
|
75
|
+
items: TDataItems | str,
|
|
76
|
+
table: TTableSchema,
|
|
77
|
+
api_key: str = dlt.secrets.value,
|
|
78
|
+
workspace_id: str = dlt.secrets.value,
|
|
79
|
+
api_base_url: str = "https://api.hotdata.dev",
|
|
80
|
+
database_name: str = "dlt",
|
|
81
|
+
schema: str = "public",
|
|
82
|
+
write_disposition: str = "append",
|
|
83
|
+
declared_tables: list[str] | None = None,
|
|
84
|
+
create_database_if_missing: bool = True,
|
|
85
|
+
max_retries: int = 5,
|
|
86
|
+
retry_backoff_seconds: float = 1.0,
|
|
87
|
+
) -> None:
|
|
88
|
+
config = HotdataDestinationConfig(
|
|
89
|
+
api_key=api_key,
|
|
90
|
+
workspace_id=workspace_id,
|
|
91
|
+
api_base_url=api_base_url,
|
|
92
|
+
database_name=database_name,
|
|
93
|
+
schema=schema,
|
|
94
|
+
write_disposition=write_disposition,
|
|
95
|
+
declared_tables=tuple(declared_tables or ()),
|
|
96
|
+
create_database_if_missing=create_database_if_missing,
|
|
97
|
+
max_retries=max_retries,
|
|
98
|
+
retry_backoff_seconds=retry_backoff_seconds,
|
|
99
|
+
)
|
|
100
|
+
contract = TableContract.from_table_schema(
|
|
101
|
+
table,
|
|
102
|
+
database_name=config.database_name,
|
|
103
|
+
schema=config.schema,
|
|
104
|
+
)
|
|
105
|
+
disposition = resolve_write_disposition(table, config.write_disposition)
|
|
106
|
+
primary_key = resolve_primary_key(table)
|
|
107
|
+
_, batch_rows = _augment_rows(table_name=contract.table_name, items=items)
|
|
108
|
+
|
|
109
|
+
client = HotdataClient(
|
|
110
|
+
api_key=config.api_key,
|
|
111
|
+
workspace_id=config.workspace_id,
|
|
112
|
+
api_base_url=config.api_base_url,
|
|
113
|
+
max_retries=config.max_retries,
|
|
114
|
+
retry_backoff_seconds=config.retry_backoff_seconds,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
parquet_path = ""
|
|
118
|
+
try:
|
|
119
|
+
client.ensure_managed_database(
|
|
120
|
+
contract.database_name,
|
|
121
|
+
schema=contract.schema,
|
|
122
|
+
tables=_declared_tables(
|
|
123
|
+
contract=contract,
|
|
124
|
+
declared_tables=list(config.declared_tables),
|
|
125
|
+
),
|
|
126
|
+
create_if_missing=config.create_database_if_missing,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
rows_to_load = batch_rows
|
|
130
|
+
if disposition != "replace":
|
|
131
|
+
existing_rows = client.fetch_table_rows(
|
|
132
|
+
database=contract.database_name,
|
|
133
|
+
schema=contract.schema,
|
|
134
|
+
table=contract.table_name,
|
|
135
|
+
)
|
|
136
|
+
rows_to_load = combine_rows(
|
|
137
|
+
disposition=disposition,
|
|
138
|
+
existing=existing_rows,
|
|
139
|
+
incoming=batch_rows,
|
|
140
|
+
primary_key=primary_key,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as handle:
|
|
144
|
+
parquet_path = handle.name
|
|
145
|
+
write_rows_parquet(rows_to_load, parquet_path)
|
|
146
|
+
|
|
147
|
+
upload_id = client.upload_parquet(parquet_path)
|
|
148
|
+
client.load_managed_table(
|
|
149
|
+
contract.database_name,
|
|
150
|
+
contract.table_name,
|
|
151
|
+
schema=contract.schema,
|
|
152
|
+
upload_id=upload_id,
|
|
153
|
+
)
|
|
154
|
+
except HotdataTerminalError as error:
|
|
155
|
+
raise DestinationTerminalException(str(error)) from error
|
|
156
|
+
finally:
|
|
157
|
+
if parquet_path:
|
|
158
|
+
os.unlink(parquet_path)
|
|
159
|
+
client.close()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hotdata.rest import ApiException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HotdataDestinationError(RuntimeError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HotdataTransientError(HotdataDestinationError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HotdataTerminalError(HotdataDestinationError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def classify_sdk_error(error: Exception) -> HotdataDestinationError:
|
|
19
|
+
if isinstance(error, TimeoutError):
|
|
20
|
+
return HotdataTransientError(str(error))
|
|
21
|
+
if isinstance(error, ConnectionError):
|
|
22
|
+
return HotdataTransientError(str(error))
|
|
23
|
+
if isinstance(error, ApiException):
|
|
24
|
+
status_code = int(error.status or 0)
|
|
25
|
+
message = f"{status_code}: {error.body or error.reason}"
|
|
26
|
+
if status_code in (408, 409, 425, 429):
|
|
27
|
+
return HotdataTransientError(message)
|
|
28
|
+
if 500 <= status_code <= 599:
|
|
29
|
+
return HotdataTransientError(message)
|
|
30
|
+
return HotdataTerminalError(message)
|
|
31
|
+
return HotdataTerminalError(str(error))
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from hotdata.rest import ApiException
|
|
8
|
+
from hotdata_runtime.client import HotdataClient as RuntimeClient
|
|
9
|
+
from hotdata_runtime.databases import LoadManagedTableResult, ManagedDatabase
|
|
10
|
+
|
|
11
|
+
from hotdata_dlt_destination.errors import (
|
|
12
|
+
HotdataTerminalError,
|
|
13
|
+
HotdataTransientError,
|
|
14
|
+
classify_sdk_error,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HotdataClient:
|
|
21
|
+
"""Managed-database client with bounded retries over hotdata-runtime."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
api_key: str,
|
|
27
|
+
workspace_id: str,
|
|
28
|
+
api_base_url: str,
|
|
29
|
+
max_retries: int,
|
|
30
|
+
retry_backoff_seconds: float,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._max_retries = max_retries
|
|
33
|
+
self._retry_backoff_seconds = retry_backoff_seconds
|
|
34
|
+
self._runtime = RuntimeClient(
|
|
35
|
+
api_key,
|
|
36
|
+
workspace_id,
|
|
37
|
+
host=api_base_url.rstrip("/"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
self._runtime.close()
|
|
42
|
+
|
|
43
|
+
def ensure_managed_database(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
*,
|
|
47
|
+
schema: str,
|
|
48
|
+
tables: list[str],
|
|
49
|
+
create_if_missing: bool,
|
|
50
|
+
) -> ManagedDatabase:
|
|
51
|
+
def operation() -> ManagedDatabase:
|
|
52
|
+
try:
|
|
53
|
+
return self._runtime.resolve_managed_database(name)
|
|
54
|
+
except KeyError:
|
|
55
|
+
if not create_if_missing:
|
|
56
|
+
raise
|
|
57
|
+
return self._runtime.create_managed_database(
|
|
58
|
+
name,
|
|
59
|
+
schema=schema,
|
|
60
|
+
tables=sorted(set(tables)),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return self._request_with_retry(operation)
|
|
64
|
+
|
|
65
|
+
def table_is_synced(
|
|
66
|
+
self,
|
|
67
|
+
database: str,
|
|
68
|
+
table: str,
|
|
69
|
+
*,
|
|
70
|
+
schema: str,
|
|
71
|
+
) -> bool:
|
|
72
|
+
for managed_table in self._runtime.list_managed_tables(database, schema=schema):
|
|
73
|
+
if managed_table.table == table:
|
|
74
|
+
return managed_table.synced
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def fetch_table_rows(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
database: str,
|
|
81
|
+
schema: str,
|
|
82
|
+
table: str,
|
|
83
|
+
) -> list[dict[str, Any]]:
|
|
84
|
+
def operation() -> list[dict[str, Any]]:
|
|
85
|
+
if not self.table_is_synced(database, table, schema=schema):
|
|
86
|
+
return []
|
|
87
|
+
qualified_table = f"{database}.{schema}.{table}"
|
|
88
|
+
result = self._runtime.execute_sql(f"SELECT * FROM {qualified_table}")
|
|
89
|
+
return result.to_records()
|
|
90
|
+
|
|
91
|
+
return self._request_with_retry(operation)
|
|
92
|
+
|
|
93
|
+
def upload_parquet(self, path: str) -> str:
|
|
94
|
+
return self._request_with_retry(lambda: self._runtime.upload_parquet(path))
|
|
95
|
+
|
|
96
|
+
def load_managed_table(
|
|
97
|
+
self,
|
|
98
|
+
database: str,
|
|
99
|
+
table: str,
|
|
100
|
+
*,
|
|
101
|
+
schema: str,
|
|
102
|
+
upload_id: str,
|
|
103
|
+
) -> LoadManagedTableResult:
|
|
104
|
+
return self._request_with_retry(
|
|
105
|
+
lambda: self._runtime.load_managed_table(
|
|
106
|
+
database,
|
|
107
|
+
table,
|
|
108
|
+
schema=schema,
|
|
109
|
+
upload_id=upload_id,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _request_with_retry(self, operation: Callable[[], T]) -> T:
|
|
114
|
+
for attempt in range(1, self._max_retries + 1):
|
|
115
|
+
try:
|
|
116
|
+
return operation()
|
|
117
|
+
except Exception as error:
|
|
118
|
+
mapped_error = self._classify_error(error)
|
|
119
|
+
if isinstance(mapped_error, HotdataTransientError) and attempt < self._max_retries:
|
|
120
|
+
time.sleep(self._retry_backoff_seconds * attempt)
|
|
121
|
+
continue
|
|
122
|
+
if isinstance(mapped_error, HotdataTransientError):
|
|
123
|
+
raise mapped_error from error
|
|
124
|
+
raise HotdataTerminalError(str(mapped_error)) from error
|
|
125
|
+
|
|
126
|
+
raise HotdataTerminalError("Unexpected retry state")
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _classify_error(error: Exception) -> Exception:
|
|
130
|
+
cause = error.__cause__ or error
|
|
131
|
+
if isinstance(cause, ApiException):
|
|
132
|
+
return classify_sdk_error(cause)
|
|
133
|
+
if isinstance(error, ApiException):
|
|
134
|
+
return classify_sdk_error(error)
|
|
135
|
+
if isinstance(error, (TimeoutError, ConnectionError)):
|
|
136
|
+
return classify_sdk_error(error)
|
|
137
|
+
return classify_sdk_error(error)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def stable_json_dumps(value: Any) -> str:
|
|
10
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compute_batch_key(table_name: str, items: Sequence[dict[str, Any]]) -> str:
|
|
14
|
+
payload = {
|
|
15
|
+
"table": table_name,
|
|
16
|
+
"items": [stable_json_dumps(item) for item in items],
|
|
17
|
+
}
|
|
18
|
+
return hashlib.sha256(stable_json_dumps(payload).encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_row_key(table_name: str, row: dict[str, Any]) -> str:
|
|
22
|
+
payload = {
|
|
23
|
+
"table": table_name,
|
|
24
|
+
"row": stable_json_dumps(row),
|
|
25
|
+
}
|
|
26
|
+
return hashlib.sha256(stable_json_dumps(payload).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dlt.common.schema import TTableSchema
|
|
6
|
+
|
|
7
|
+
SUPPORTED_WRITE_DISPOSITIONS = frozenset({"replace", "append", "merge", "upsert"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_write_disposition(table: TTableSchema, default: str) -> str:
|
|
11
|
+
disposition = table.get("write_disposition") or default
|
|
12
|
+
return str(disposition).lower()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_primary_key(table: TTableSchema) -> list[str] | None:
|
|
16
|
+
primary_key = table.get("primary_key")
|
|
17
|
+
if primary_key is None:
|
|
18
|
+
return None
|
|
19
|
+
if isinstance(primary_key, str):
|
|
20
|
+
return [primary_key]
|
|
21
|
+
return [str(key) for key in primary_key]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def row_key(row: dict[str, Any], keys: list[str]) -> tuple[Any, ...]:
|
|
25
|
+
return tuple(row.get(key) for key in keys)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def append_rows(
|
|
29
|
+
existing: list[dict[str, Any]],
|
|
30
|
+
incoming: list[dict[str, Any]],
|
|
31
|
+
) -> list[dict[str, Any]]:
|
|
32
|
+
return [*existing, *incoming]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def merge_rows(
|
|
36
|
+
existing: list[dict[str, Any]],
|
|
37
|
+
incoming: list[dict[str, Any]],
|
|
38
|
+
*,
|
|
39
|
+
primary_key: list[str],
|
|
40
|
+
) -> list[dict[str, Any]]:
|
|
41
|
+
merged = list(existing)
|
|
42
|
+
index = {row_key(row, primary_key): position for position, row in enumerate(merged)}
|
|
43
|
+
for row in incoming:
|
|
44
|
+
key = row_key(row, primary_key)
|
|
45
|
+
if key in index:
|
|
46
|
+
merged[index[key]] = row
|
|
47
|
+
else:
|
|
48
|
+
index[key] = len(merged)
|
|
49
|
+
merged.append(row)
|
|
50
|
+
return merged
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def combine_rows(
|
|
54
|
+
*,
|
|
55
|
+
disposition: str,
|
|
56
|
+
existing: list[dict[str, Any]],
|
|
57
|
+
incoming: list[dict[str, Any]],
|
|
58
|
+
primary_key: list[str] | None,
|
|
59
|
+
) -> list[dict[str, Any]]:
|
|
60
|
+
if disposition == "replace":
|
|
61
|
+
return incoming
|
|
62
|
+
if disposition in ("merge", "upsert"):
|
|
63
|
+
keys = primary_key or ["_hotdata_row_key"]
|
|
64
|
+
return merge_rows(existing, incoming, primary_key=keys)
|
|
65
|
+
if disposition == "append":
|
|
66
|
+
return append_rows(existing, incoming)
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Unsupported write_disposition {disposition!r}. "
|
|
69
|
+
f"Expected one of: {', '.join(sorted(SUPPORTED_WRITE_DISPOSITIONS))}"
|
|
70
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
import pyarrow.parquet as pq
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_parquet_rows(path: str | Path) -> list[dict[str, Any]]:
|
|
11
|
+
table = pq.read_table(Path(path))
|
|
12
|
+
return table.to_pylist()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_rows_parquet(rows: list[dict[str, Any]], path: str | Path) -> None:
|
|
16
|
+
table = pa.Table.from_pylist(rows)
|
|
17
|
+
pq.write_table(table, Path(path))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dlt
|
|
4
|
+
|
|
5
|
+
from hotdata_dlt_destination.destination import hotdata_destination
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dlt.resource(name="customers", write_disposition="append")
|
|
9
|
+
def customers_resource() -> list[dict[str, object]]:
|
|
10
|
+
return [
|
|
11
|
+
{"id": 1, "name": "Acme", "tier": "enterprise", "is_active": True},
|
|
12
|
+
{"id": 2, "name": "Globex", "tier": "startup", "is_active": True},
|
|
13
|
+
{"id": 3, "name": "Initech", "tier": "midmarket", "is_active": False},
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
pipeline = dlt.pipeline(
|
|
19
|
+
pipeline_name="hotdata_basic",
|
|
20
|
+
destination=hotdata_destination(
|
|
21
|
+
write_disposition="append",
|
|
22
|
+
declared_tables=["customers"],
|
|
23
|
+
), # type: ignore[call-arg]
|
|
24
|
+
dataset_name="hotdata_basic",
|
|
25
|
+
)
|
|
26
|
+
load_info = pipeline.run(customers_resource())
|
|
27
|
+
print(load_info)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
import dlt
|
|
6
|
+
|
|
7
|
+
from hotdata_dlt_destination.destination import hotdata_destination
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dlt.resource(name="orders", write_disposition="merge", primary_key="id")
|
|
11
|
+
def orders_resource(since: str = "2026-01-01T00:00:00+00:00") -> list[dict[str, object]]:
|
|
12
|
+
watermark = datetime.fromisoformat(since)
|
|
13
|
+
rows = [
|
|
14
|
+
{"id": 101, "status": "new", "updated_at": "2026-01-01T01:00:00+00:00", "amount": 120.50},
|
|
15
|
+
{"id": 102, "status": "paid", "updated_at": "2026-01-02T01:00:00+00:00", "amount": 50.00},
|
|
16
|
+
{"id": 101, "status": "paid", "updated_at": "2026-01-03T01:00:00+00:00", "amount": 120.50},
|
|
17
|
+
]
|
|
18
|
+
return [
|
|
19
|
+
row
|
|
20
|
+
for row in rows
|
|
21
|
+
if datetime.fromisoformat(str(row["updated_at"])).replace(tzinfo=UTC) > watermark
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
pipeline = dlt.pipeline(
|
|
27
|
+
pipeline_name="hotdata_incremental",
|
|
28
|
+
destination=hotdata_destination(
|
|
29
|
+
write_disposition="upsert",
|
|
30
|
+
declared_tables=["orders"],
|
|
31
|
+
), # type: ignore[call-arg]
|
|
32
|
+
dataset_name="hotdata_incremental",
|
|
33
|
+
)
|
|
34
|
+
load_info = pipeline.run(orders_resource())
|
|
35
|
+
print(load_info)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib import request
|
|
7
|
+
|
|
8
|
+
import dlt
|
|
9
|
+
|
|
10
|
+
from hotdata_dlt_destination.destination import hotdata_destination
|
|
11
|
+
|
|
12
|
+
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_linear_issues(
|
|
16
|
+
*,
|
|
17
|
+
api_key: str,
|
|
18
|
+
issue_limit: int,
|
|
19
|
+
team_key: str | None,
|
|
20
|
+
) -> list[dict[str, Any]]:
|
|
21
|
+
if team_key:
|
|
22
|
+
query = """
|
|
23
|
+
query Issues($first: Int!, $teamKey: String!) {
|
|
24
|
+
issues(
|
|
25
|
+
first: $first
|
|
26
|
+
orderBy: updatedAt
|
|
27
|
+
filter: {team: {key: {eq: $teamKey}}}
|
|
28
|
+
) {
|
|
29
|
+
nodes {
|
|
30
|
+
id
|
|
31
|
+
identifier
|
|
32
|
+
title
|
|
33
|
+
priority
|
|
34
|
+
createdAt
|
|
35
|
+
updatedAt
|
|
36
|
+
url
|
|
37
|
+
state { name type }
|
|
38
|
+
team { id key name }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
variables: dict[str, Any] = {"first": issue_limit, "teamKey": team_key}
|
|
44
|
+
else:
|
|
45
|
+
query = """
|
|
46
|
+
query Issues($first: Int!) {
|
|
47
|
+
issues(first: $first, orderBy: updatedAt) {
|
|
48
|
+
nodes {
|
|
49
|
+
id
|
|
50
|
+
identifier
|
|
51
|
+
title
|
|
52
|
+
priority
|
|
53
|
+
createdAt
|
|
54
|
+
updatedAt
|
|
55
|
+
url
|
|
56
|
+
state { name type }
|
|
57
|
+
team { id key name }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
variables = {"first": issue_limit}
|
|
63
|
+
|
|
64
|
+
payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
|
|
65
|
+
req = request.Request(
|
|
66
|
+
LINEAR_GRAPHQL_URL,
|
|
67
|
+
data=payload,
|
|
68
|
+
headers={
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"Authorization": api_key,
|
|
71
|
+
},
|
|
72
|
+
method="POST",
|
|
73
|
+
)
|
|
74
|
+
with request.urlopen(req) as response:
|
|
75
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
76
|
+
return body["data"]["issues"]["nodes"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dlt.resource(name="linear_issues", write_disposition="merge", primary_key="id")
|
|
80
|
+
def linear_issues_resource(
|
|
81
|
+
linear_api_key: str,
|
|
82
|
+
linear_issue_limit: int = 25,
|
|
83
|
+
linear_team_key: str | None = None,
|
|
84
|
+
) -> list[dict[str, Any]]:
|
|
85
|
+
return fetch_linear_issues(
|
|
86
|
+
api_key=linear_api_key,
|
|
87
|
+
issue_limit=linear_issue_limit,
|
|
88
|
+
team_key=linear_team_key,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
pipeline = dlt.pipeline(
|
|
94
|
+
pipeline_name="hotdata_linear",
|
|
95
|
+
destination=hotdata_destination(
|
|
96
|
+
write_disposition="upsert",
|
|
97
|
+
database_name="linear",
|
|
98
|
+
declared_tables=["linear_issues"],
|
|
99
|
+
), # type: ignore[call-arg]
|
|
100
|
+
dataset_name="hotdata_linear",
|
|
101
|
+
)
|
|
102
|
+
linear_api_key = os.environ["LINEAR_API_KEY"]
|
|
103
|
+
linear_team_key = os.environ.get("LINEAR_TEAM_KEY")
|
|
104
|
+
issue_limit = int(os.environ.get("LINEAR_ISSUE_LIMIT", "25"))
|
|
105
|
+
load_info = pipeline.run(
|
|
106
|
+
linear_issues_resource(
|
|
107
|
+
linear_api_key=linear_api_key,
|
|
108
|
+
linear_team_key=linear_team_key,
|
|
109
|
+
linear_issue_limit=issue_limit,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
print(load_info)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: hotdata-dlt-destination
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: dlt destination for loading data into Hotdata managed databases.
|
|
5
|
+
Author-email: 669988+eddietejeda@users.noreply.github.com
|
|
6
|
+
Requires-Dist: dlt>=1.26.0
|
|
7
|
+
Requires-Dist: hotdata>=0.2.2
|
|
8
|
+
Requires-Dist: hotdata-runtime>=0.1.1
|
|
9
|
+
Requires-Dist: pyarrow>=14
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# hotdata-dlt-destination
|
|
14
|
+
|
|
15
|
+
`hotdata-dlt-destination` is a Python package that implements a custom [dlt destination](https://dlthub.com/docs/dlt-ecosystem/destinations/destination) for loading data into **Hotdata managed databases** with deterministic idempotency keys and explicit write semantics.
|
|
16
|
+
|
|
17
|
+
## What this repo includes
|
|
18
|
+
|
|
19
|
+
- Custom destination via `@dlt.destination` in `src/hotdata_dlt_destination/destination.py`
|
|
20
|
+
- Managed-database ingestion through `hotdata-runtime` (`upload_parquet`, `load_managed_table`, `SELECT`)
|
|
21
|
+
- Read-modify-write append/merge using only supported API operations
|
|
22
|
+
- Deterministic batch and row idempotency keys
|
|
23
|
+
- Example pipelines:
|
|
24
|
+
- `hotdata-dlt-basic-pipeline` (append)
|
|
25
|
+
- `hotdata-dlt-incremental-pipeline` (upsert/merge)
|
|
26
|
+
- `hotdata-dlt-linear-pipeline` (Linear issues → Hotdata)
|
|
27
|
+
- Unit tests in `tests/`
|
|
28
|
+
- Architecture and runbook docs in `docs/`
|
|
29
|
+
|
|
30
|
+
## Data contract defaults
|
|
31
|
+
|
|
32
|
+
- Managed database: `database_name` (default `dlt`, created on first load when missing)
|
|
33
|
+
- Schema: `public`
|
|
34
|
+
- Table name: normalized lowercase dlt table identifier
|
|
35
|
+
- Nested table names: `{parent}__{child}`
|
|
36
|
+
- Write semantics (all use `load_managed_table(replace)` under the hood):
|
|
37
|
+
- `replace`: upload batch parquet and replace the target table
|
|
38
|
+
- `append`: read existing target rows, append batch in Python, replace target
|
|
39
|
+
- `upsert`/`merge`: read existing rows, upsert by dlt `primary_key` (or `_hotdata_row_key`), replace target
|
|
40
|
+
- Idempotency:
|
|
41
|
+
- Batch key `_hotdata_batch_key` = hash(table + full batch payload)
|
|
42
|
+
- Row key `_hotdata_row_key` = hash(table + canonical row payload)
|
|
43
|
+
|
|
44
|
+
## Configure
|
|
45
|
+
|
|
46
|
+
Set environment variables (or pass destination kwargs / dlt secrets):
|
|
47
|
+
|
|
48
|
+
- `HOTDATA_API_KEY`
|
|
49
|
+
- `HOTDATA_WORKSPACE`
|
|
50
|
+
- `HOTDATA_DATABASE` (managed database name, default `dlt`)
|
|
51
|
+
- optional: `HOTDATA_SCHEMA`, `HOTDATA_WRITE_DISPOSITION`, `HOTDATA_DECLARED_TABLES`, retry tuning
|
|
52
|
+
|
|
53
|
+
For pipelines with multiple tables, declare every target table when the managed database is first created:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
hotdata_destination(
|
|
57
|
+
database_name="analytics",
|
|
58
|
+
declared_tables=["customers", "orders", "orders__items"],
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import dlt
|
|
66
|
+
from hotdata_dlt_destination import hotdata_destination
|
|
67
|
+
|
|
68
|
+
pipeline = dlt.pipeline(
|
|
69
|
+
pipeline_name="my_pipeline",
|
|
70
|
+
destination=hotdata_destination(
|
|
71
|
+
database_name="analytics",
|
|
72
|
+
write_disposition="append",
|
|
73
|
+
declared_tables=["customers"],
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
pipeline.run(my_resource())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Per-resource `write_disposition` and `primary_key` from dlt take precedence over the destination default.
|
|
80
|
+
|
|
81
|
+
## Developer workflow
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv sync
|
|
85
|
+
uv run ruff check .
|
|
86
|
+
uv run pytest
|
|
87
|
+
uv run hotdata-dlt-destination
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run pipelines:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv run hotdata-dlt-basic-pipeline
|
|
94
|
+
uv run hotdata-dlt-incremental-pipeline
|
|
95
|
+
uv run hotdata-dlt-linear-pipeline
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Run the live end-to-end integration test (requires Hotdata + Linear env vars):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run pytest tests/test_e2e_linear_hotdata.py -m integration
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## References
|
|
105
|
+
|
|
106
|
+
- [Hotdata Python SDK](https://github.com/hotdata-dev/sdk-python)
|
|
107
|
+
- [hotdata-runtime](https://github.com/hotdata-dev/hotdata-runtime)
|
|
108
|
+
- [dlt custom destination](https://dlthub.com/docs/dlt-ecosystem/destinations/destination)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
hotdata_dlt_destination/__init__.py,sha256=hff97-_J8MfYcy1CHJ0nEkSW5XphL2jNoYEKDIMSTWQ,103
|
|
2
|
+
hotdata_dlt_destination/cli.py,sha256=aTdERzqgJiInAbxXrVTgPKM6NsYtVS7vxD_DDdYx_Hg,471
|
|
3
|
+
hotdata_dlt_destination/config.py,sha256=77Y9e1RMVvQz6-pcgCHm92VDiPhpSamsqYt5fwu98gY,1556
|
|
4
|
+
hotdata_dlt_destination/contracts.py,sha256=H3iMWSWPInT_173hxMhvTgMEmLR_Tbje5npJZDuMJHE,1732
|
|
5
|
+
hotdata_dlt_destination/destination.py,sha256=-s3QrlyP5FwSNynVun_14NBXWUT2rohTbdkJSs2EaBg,5162
|
|
6
|
+
hotdata_dlt_destination/errors.py,sha256=prj76YG0YzCY-vpcqCq2xvb1LT1JbWNZnNS7MDzYoSQ,936
|
|
7
|
+
hotdata_dlt_destination/hotdata_client.py,sha256=KmxrEtTYSRZSV3P4RnCTAF9XwO943qmWJV9fWSBmKoo,4266
|
|
8
|
+
hotdata_dlt_destination/idempotency.py,sha256=iW5Qhb1LmwtmpNmL3XN7xOoKt0p_hW7M3ZiGXMSOfPI,765
|
|
9
|
+
hotdata_dlt_destination/merge.py,sha256=FwB6voZt8PvZ1DrqBZNyOsvmodaIlPubAVX9p3Iu9OE,2037
|
|
10
|
+
hotdata_dlt_destination/parquet.py,sha256=GCZ7RzpiCXWMq-vR3id2mP3k3SwlKJ2u_F0p1zIdxfQ,426
|
|
11
|
+
hotdata_dlt_destination/pipelines/__init__.py,sha256=da1PTClDMl-IBkrSvq6JC1lnS-K_BASzCvxVhNxN5Ls,13
|
|
12
|
+
hotdata_dlt_destination/pipelines/basic_pipeline.py,sha256=rF_M1-aDl6sEVQQ4SPtJ6V8qWVzxwqbMz-yD21X4hN8,887
|
|
13
|
+
hotdata_dlt_destination/pipelines/incremental_pipeline.py,sha256=doCDF8AWKznuhYpuVeTUkv8DDzVU5x6qJpJxOGJUZXg,1239
|
|
14
|
+
hotdata_dlt_destination/pipelines/linear_pipeline.py,sha256=koc8-zJ2nIdMZ5isE7I6edD211cECEraAfW4z5Iq8vM,3034
|
|
15
|
+
hotdata_dlt_destination-0.3.0.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
|
|
16
|
+
hotdata_dlt_destination-0.3.0.dist-info/entry_points.txt,sha256=wzZqzxtbIJ7q17dFJNbGGmaC4xyLJvA3ZTiSYaF09Kc,341
|
|
17
|
+
hotdata_dlt_destination-0.3.0.dist-info/METADATA,sha256=OdiSZqtqw9XoK6qrGHx4M_VOn27ewRRvf1dvNFQju3Y,3601
|
|
18
|
+
hotdata_dlt_destination-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
hotdata-dlt-basic-pipeline = hotdata_dlt_destination.pipelines.basic_pipeline:main
|
|
3
|
+
hotdata-dlt-destination = hotdata_dlt_destination.cli:main
|
|
4
|
+
hotdata-dlt-incremental-pipeline = hotdata_dlt_destination.pipelines.incremental_pipeline:main
|
|
5
|
+
hotdata-dlt-linear-pipeline = hotdata_dlt_destination.pipelines.linear_pipeline:main
|
|
6
|
+
|