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.
@@ -0,0 +1,3 @@
1
+ from hotdata_dlt_destination.destination import hotdata_destination
2
+
3
+ __all__ = ["hotdata_destination"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+