ingestr 0.13.2__py3-none-any.whl → 0.14.104__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.
- ingestr/conftest.py +72 -0
- ingestr/main.py +134 -87
- ingestr/src/adjust/__init__.py +4 -4
- ingestr/src/adjust/adjust_helpers.py +7 -3
- ingestr/src/airtable/__init__.py +3 -2
- ingestr/src/allium/__init__.py +128 -0
- ingestr/src/anthropic/__init__.py +277 -0
- ingestr/src/anthropic/helpers.py +525 -0
- ingestr/src/applovin/__init__.py +262 -0
- ingestr/src/applovin_max/__init__.py +117 -0
- ingestr/src/appsflyer/__init__.py +325 -0
- ingestr/src/appsflyer/client.py +49 -45
- ingestr/src/appstore/__init__.py +1 -0
- ingestr/src/arrow/__init__.py +9 -1
- ingestr/src/asana_source/__init__.py +1 -1
- ingestr/src/attio/__init__.py +102 -0
- ingestr/src/attio/helpers.py +65 -0
- ingestr/src/blob.py +38 -11
- ingestr/src/buildinfo.py +1 -0
- ingestr/src/chess/__init__.py +1 -1
- ingestr/src/clickup/__init__.py +85 -0
- ingestr/src/clickup/helpers.py +47 -0
- ingestr/src/collector/spinner.py +43 -0
- ingestr/src/couchbase_source/__init__.py +118 -0
- ingestr/src/couchbase_source/helpers.py +135 -0
- ingestr/src/cursor/__init__.py +83 -0
- ingestr/src/cursor/helpers.py +188 -0
- ingestr/src/destinations.py +520 -33
- ingestr/src/docebo/__init__.py +589 -0
- ingestr/src/docebo/client.py +435 -0
- ingestr/src/docebo/helpers.py +97 -0
- ingestr/src/elasticsearch/__init__.py +80 -0
- ingestr/src/elasticsearch/helpers.py +138 -0
- ingestr/src/errors.py +8 -0
- ingestr/src/facebook_ads/__init__.py +47 -28
- ingestr/src/facebook_ads/helpers.py +59 -37
- ingestr/src/facebook_ads/settings.py +2 -0
- ingestr/src/facebook_ads/utils.py +39 -0
- ingestr/src/factory.py +116 -2
- ingestr/src/filesystem/__init__.py +8 -3
- ingestr/src/filters.py +46 -3
- ingestr/src/fluxx/__init__.py +9906 -0
- ingestr/src/fluxx/helpers.py +209 -0
- ingestr/src/frankfurter/__init__.py +157 -0
- ingestr/src/frankfurter/helpers.py +48 -0
- ingestr/src/freshdesk/__init__.py +89 -0
- ingestr/src/freshdesk/freshdesk_client.py +137 -0
- ingestr/src/freshdesk/settings.py +9 -0
- ingestr/src/fundraiseup/__init__.py +95 -0
- ingestr/src/fundraiseup/client.py +81 -0
- ingestr/src/github/__init__.py +41 -6
- ingestr/src/github/helpers.py +5 -5
- ingestr/src/google_analytics/__init__.py +22 -4
- ingestr/src/google_analytics/helpers.py +124 -6
- ingestr/src/google_sheets/__init__.py +4 -4
- ingestr/src/google_sheets/helpers/data_processing.py +2 -2
- ingestr/src/hostaway/__init__.py +302 -0
- ingestr/src/hostaway/client.py +288 -0
- ingestr/src/http/__init__.py +35 -0
- ingestr/src/http/readers.py +114 -0
- ingestr/src/http_client.py +24 -0
- ingestr/src/hubspot/__init__.py +66 -23
- ingestr/src/hubspot/helpers.py +52 -22
- ingestr/src/hubspot/settings.py +14 -7
- ingestr/src/influxdb/__init__.py +46 -0
- ingestr/src/influxdb/client.py +34 -0
- ingestr/src/intercom/__init__.py +142 -0
- ingestr/src/intercom/helpers.py +674 -0
- ingestr/src/intercom/settings.py +279 -0
- ingestr/src/isoc_pulse/__init__.py +159 -0
- ingestr/src/jira_source/__init__.py +340 -0
- ingestr/src/jira_source/helpers.py +439 -0
- ingestr/src/jira_source/settings.py +170 -0
- ingestr/src/kafka/__init__.py +4 -1
- ingestr/src/kinesis/__init__.py +139 -0
- ingestr/src/kinesis/helpers.py +82 -0
- ingestr/src/klaviyo/{_init_.py → __init__.py} +5 -6
- ingestr/src/linear/__init__.py +634 -0
- ingestr/src/linear/helpers.py +111 -0
- ingestr/src/linkedin_ads/helpers.py +0 -1
- ingestr/src/loader.py +69 -0
- ingestr/src/mailchimp/__init__.py +126 -0
- ingestr/src/mailchimp/helpers.py +226 -0
- ingestr/src/mailchimp/settings.py +164 -0
- ingestr/src/masking.py +344 -0
- ingestr/src/mixpanel/__init__.py +62 -0
- ingestr/src/mixpanel/client.py +99 -0
- ingestr/src/monday/__init__.py +246 -0
- ingestr/src/monday/helpers.py +392 -0
- ingestr/src/monday/settings.py +328 -0
- ingestr/src/mongodb/__init__.py +72 -8
- ingestr/src/mongodb/helpers.py +915 -38
- ingestr/src/partition.py +32 -0
- ingestr/src/personio/__init__.py +331 -0
- ingestr/src/personio/helpers.py +86 -0
- ingestr/src/phantombuster/__init__.py +65 -0
- ingestr/src/phantombuster/client.py +87 -0
- ingestr/src/pinterest/__init__.py +82 -0
- ingestr/src/pipedrive/__init__.py +198 -0
- ingestr/src/pipedrive/helpers/__init__.py +23 -0
- ingestr/src/pipedrive/helpers/custom_fields_munger.py +102 -0
- ingestr/src/pipedrive/helpers/pages.py +115 -0
- ingestr/src/pipedrive/settings.py +27 -0
- ingestr/src/pipedrive/typing.py +3 -0
- ingestr/src/plusvibeai/__init__.py +335 -0
- ingestr/src/plusvibeai/helpers.py +544 -0
- ingestr/src/plusvibeai/settings.py +252 -0
- ingestr/src/quickbooks/__init__.py +117 -0
- ingestr/src/resource.py +40 -0
- ingestr/src/revenuecat/__init__.py +83 -0
- ingestr/src/revenuecat/helpers.py +237 -0
- ingestr/src/salesforce/__init__.py +156 -0
- ingestr/src/salesforce/helpers.py +64 -0
- ingestr/src/shopify/__init__.py +1 -17
- ingestr/src/smartsheets/__init__.py +82 -0
- ingestr/src/snapchat_ads/__init__.py +489 -0
- ingestr/src/snapchat_ads/client.py +72 -0
- ingestr/src/snapchat_ads/helpers.py +535 -0
- ingestr/src/socrata_source/__init__.py +83 -0
- ingestr/src/socrata_source/helpers.py +85 -0
- ingestr/src/socrata_source/settings.py +8 -0
- ingestr/src/solidgate/__init__.py +219 -0
- ingestr/src/solidgate/helpers.py +154 -0
- ingestr/src/sources.py +3132 -212
- ingestr/src/stripe_analytics/__init__.py +49 -21
- ingestr/src/stripe_analytics/helpers.py +286 -1
- ingestr/src/stripe_analytics/settings.py +62 -10
- ingestr/src/telemetry/event.py +10 -9
- ingestr/src/tiktok_ads/__init__.py +12 -6
- ingestr/src/tiktok_ads/tiktok_helpers.py +0 -1
- ingestr/src/trustpilot/__init__.py +48 -0
- ingestr/src/trustpilot/client.py +48 -0
- ingestr/src/version.py +6 -1
- ingestr/src/wise/__init__.py +68 -0
- ingestr/src/wise/client.py +63 -0
- ingestr/src/zoom/__init__.py +99 -0
- ingestr/src/zoom/helpers.py +102 -0
- ingestr/tests/unit/test_smartsheets.py +133 -0
- ingestr-0.14.104.dist-info/METADATA +563 -0
- ingestr-0.14.104.dist-info/RECORD +203 -0
- ingestr/src/appsflyer/_init_.py +0 -24
- ingestr-0.13.2.dist-info/METADATA +0 -302
- ingestr-0.13.2.dist-info/RECORD +0 -107
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/WHEEL +0 -0
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/entry_points.txt +0 -0
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions and API client for Intercom integration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Callable, Dict, Iterator, Optional, Union
|
|
8
|
+
|
|
9
|
+
from dlt.common.typing import TDataItem, TDataItems, TSecretValue
|
|
10
|
+
|
|
11
|
+
from ingestr.src.http_client import create_client
|
|
12
|
+
|
|
13
|
+
from .settings import (
|
|
14
|
+
API_VERSION,
|
|
15
|
+
DEFAULT_PAGE_SIZE,
|
|
16
|
+
REGIONAL_ENDPOINTS,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PaginationType(Enum):
|
|
21
|
+
"""Types of pagination supported by Intercom API."""
|
|
22
|
+
|
|
23
|
+
CURSOR = "cursor"
|
|
24
|
+
SCROLL = "scroll"
|
|
25
|
+
SIMPLE = "simple" # No pagination, single page
|
|
26
|
+
SEARCH = "search" # Search API pagination
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IntercomCredentials:
|
|
30
|
+
"""Base class for Intercom credentials."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, region: str = "us"):
|
|
33
|
+
self.region = region
|
|
34
|
+
if self.region not in REGIONAL_ENDPOINTS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid region: {self.region}. Must be one of {list(REGIONAL_ENDPOINTS.keys())}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def base_url(self) -> str:
|
|
41
|
+
"""Get the base URL for the specified region."""
|
|
42
|
+
return REGIONAL_ENDPOINTS[self.region]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class IntercomCredentialsAccessToken(IntercomCredentials):
|
|
47
|
+
"""Credentials for Intercom API using Access Token authentication."""
|
|
48
|
+
|
|
49
|
+
access_token: TSecretValue
|
|
50
|
+
region: str = "us" # us, eu, or au
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
super().__init__(self.region)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class IntercomCredentialsOAuth(IntercomCredentials):
|
|
58
|
+
"""Credentials for Intercom API using OAuth authentication."""
|
|
59
|
+
|
|
60
|
+
oauth_token: TSecretValue
|
|
61
|
+
region: str = "us" # us, eu, or au
|
|
62
|
+
|
|
63
|
+
def __post_init__(self):
|
|
64
|
+
super().__init__(self.region)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
TIntercomCredentials = Union[IntercomCredentialsAccessToken, IntercomCredentialsOAuth]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IntercomAPIClient:
|
|
71
|
+
"""
|
|
72
|
+
API client for making requests to Intercom API.
|
|
73
|
+
Handles authentication, pagination, and rate limiting.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, credentials: TIntercomCredentials):
|
|
77
|
+
"""
|
|
78
|
+
Initialize the Intercom API client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
credentials: Intercom API credentials
|
|
82
|
+
"""
|
|
83
|
+
self.credentials = credentials
|
|
84
|
+
self.base_url = credentials.base_url
|
|
85
|
+
|
|
86
|
+
# Set up authentication headers
|
|
87
|
+
self.headers = {
|
|
88
|
+
"Accept": "application/json",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Intercom-Version": API_VERSION, # REQUIRED header
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if isinstance(credentials, IntercomCredentialsAccessToken):
|
|
94
|
+
self.headers["Authorization"] = f"Bearer {credentials.access_token}"
|
|
95
|
+
elif isinstance(credentials, IntercomCredentialsOAuth):
|
|
96
|
+
self.headers["Authorization"] = f"Bearer {credentials.oauth_token}"
|
|
97
|
+
else:
|
|
98
|
+
raise TypeError(
|
|
99
|
+
"Invalid credentials type. Must be IntercomCredentialsAccessToken or IntercomCredentialsOAuth"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Create HTTP client with rate limit retry for 429 status codes
|
|
103
|
+
self.client = create_client(retry_status_codes=[429, 502, 503])
|
|
104
|
+
|
|
105
|
+
def _make_request(
|
|
106
|
+
self,
|
|
107
|
+
method: str,
|
|
108
|
+
endpoint: str,
|
|
109
|
+
params: Optional[Dict[str, Any]] = None,
|
|
110
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
111
|
+
) -> Dict[str, Any]:
|
|
112
|
+
"""
|
|
113
|
+
Make a request to the Intercom API.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
method: HTTP method (GET, POST, etc.)
|
|
117
|
+
endpoint: API endpoint path
|
|
118
|
+
params: Query parameters
|
|
119
|
+
json_data: JSON body data
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Response JSON data
|
|
123
|
+
"""
|
|
124
|
+
url = f"{self.base_url}{endpoint}"
|
|
125
|
+
|
|
126
|
+
if method.upper() == "GET":
|
|
127
|
+
response = self.client.get(url, headers=self.headers, params=params)
|
|
128
|
+
elif method.upper() == "POST":
|
|
129
|
+
response = self.client.post(
|
|
130
|
+
url, headers=self.headers, json=json_data, params=params
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
response = self.client.request(
|
|
134
|
+
method, url, headers=self.headers, json=json_data, params=params
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# The create_client already handles rate limiting (429) with retries
|
|
138
|
+
# Just check for other errors
|
|
139
|
+
if response.status_code >= 400:
|
|
140
|
+
error_msg = f"Intercom API error {response.status_code}: {response.text}"
|
|
141
|
+
raise Exception(error_msg)
|
|
142
|
+
|
|
143
|
+
return response.json()
|
|
144
|
+
|
|
145
|
+
def get_pages(
|
|
146
|
+
self,
|
|
147
|
+
endpoint: str,
|
|
148
|
+
data_key: str,
|
|
149
|
+
pagination_type: PaginationType,
|
|
150
|
+
params: Optional[Dict[str, Any]] = None,
|
|
151
|
+
search_query: Optional[Dict[str, Any]] = None,
|
|
152
|
+
) -> Iterator[TDataItems]:
|
|
153
|
+
"""
|
|
154
|
+
Get paginated data from an Intercom endpoint.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
endpoint: API endpoint path
|
|
158
|
+
data_key: Key in response containing the data items
|
|
159
|
+
pagination_type: Type of pagination to use
|
|
160
|
+
params: Query parameters
|
|
161
|
+
search_query: Search query for search endpoints
|
|
162
|
+
|
|
163
|
+
Yields:
|
|
164
|
+
Lists of data items from each page
|
|
165
|
+
"""
|
|
166
|
+
params = params or {}
|
|
167
|
+
|
|
168
|
+
if pagination_type == PaginationType.SIMPLE:
|
|
169
|
+
# Single page, no pagination
|
|
170
|
+
response = self._make_request("GET", endpoint, params)
|
|
171
|
+
if data_key in response:
|
|
172
|
+
yield response[data_key]
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
elif pagination_type == PaginationType.CURSOR:
|
|
176
|
+
# Cursor-based pagination
|
|
177
|
+
params["per_page"] = params.get("per_page", DEFAULT_PAGE_SIZE)
|
|
178
|
+
next_cursor = None
|
|
179
|
+
|
|
180
|
+
while True:
|
|
181
|
+
if next_cursor:
|
|
182
|
+
params["starting_after"] = next_cursor
|
|
183
|
+
|
|
184
|
+
response = self._make_request("GET", endpoint, params)
|
|
185
|
+
|
|
186
|
+
# Yield the data
|
|
187
|
+
if data_key in response and response[data_key]:
|
|
188
|
+
yield response[data_key]
|
|
189
|
+
|
|
190
|
+
# Check for next page
|
|
191
|
+
pages_info = response.get("pages", {})
|
|
192
|
+
if not pages_info.get("next"):
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
next_cursor = pages_info.get("next", {}).get("starting_after")
|
|
196
|
+
if not next_cursor:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
elif pagination_type == PaginationType.SCROLL:
|
|
200
|
+
# Scroll API pagination (for large exports)
|
|
201
|
+
scroll_param = None
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
scroll_endpoint = endpoint
|
|
205
|
+
if scroll_param:
|
|
206
|
+
scroll_endpoint = f"{endpoint}/scroll"
|
|
207
|
+
params = {"scroll_param": scroll_param}
|
|
208
|
+
|
|
209
|
+
response = self._make_request("GET", scroll_endpoint, params)
|
|
210
|
+
|
|
211
|
+
# Yield the data
|
|
212
|
+
if data_key in response and response[data_key]:
|
|
213
|
+
yield response[data_key]
|
|
214
|
+
|
|
215
|
+
# Get next scroll parameter
|
|
216
|
+
scroll_param = response.get("scroll_param")
|
|
217
|
+
if not scroll_param:
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
elif pagination_type == PaginationType.SEARCH:
|
|
221
|
+
# Search API pagination
|
|
222
|
+
if not search_query:
|
|
223
|
+
raise ValueError("Search query required for search pagination")
|
|
224
|
+
|
|
225
|
+
pagination_info = search_query.get("pagination", {})
|
|
226
|
+
pagination_info["per_page"] = pagination_info.get(
|
|
227
|
+
"per_page", DEFAULT_PAGE_SIZE
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
while True:
|
|
231
|
+
# Build search request
|
|
232
|
+
request_data = {
|
|
233
|
+
"query": search_query.get("query", {}),
|
|
234
|
+
"pagination": pagination_info,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if "sort" in search_query:
|
|
238
|
+
request_data["sort"] = search_query["sort"]
|
|
239
|
+
|
|
240
|
+
response = self._make_request("POST", endpoint, json_data=request_data)
|
|
241
|
+
|
|
242
|
+
# Yield the data
|
|
243
|
+
if data_key in response and response[data_key]:
|
|
244
|
+
yield response[data_key]
|
|
245
|
+
|
|
246
|
+
# Check for next page
|
|
247
|
+
pages_info = response.get("pages", {})
|
|
248
|
+
if not pages_info.get("next"):
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
next_cursor = pages_info.get("next", {}).get("starting_after")
|
|
252
|
+
if not next_cursor:
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
pagination_info["starting_after"] = next_cursor
|
|
256
|
+
|
|
257
|
+
def get_single_resource(self, endpoint: str, resource_id: str) -> TDataItem:
|
|
258
|
+
"""
|
|
259
|
+
Get a single resource by ID.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
endpoint: Base endpoint path
|
|
263
|
+
resource_id: Resource ID
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Resource data
|
|
267
|
+
"""
|
|
268
|
+
return self._make_request("GET", f"{endpoint}/{resource_id}")
|
|
269
|
+
|
|
270
|
+
def search(
|
|
271
|
+
self,
|
|
272
|
+
resource_type: str,
|
|
273
|
+
query: Dict[str, Any],
|
|
274
|
+
sort: Optional[Dict[str, str]] = None,
|
|
275
|
+
) -> Iterator[TDataItems]:
|
|
276
|
+
"""
|
|
277
|
+
Search for resources using the Search API.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
resource_type: Type of resource to search (contacts, companies, conversations)
|
|
281
|
+
query: Search query following Intercom's query format
|
|
282
|
+
sort: Optional sort configuration
|
|
283
|
+
|
|
284
|
+
Yields:
|
|
285
|
+
Lists of matching resources
|
|
286
|
+
"""
|
|
287
|
+
endpoint = f"/{resource_type}/search"
|
|
288
|
+
search_query = {"query": query}
|
|
289
|
+
|
|
290
|
+
if sort:
|
|
291
|
+
search_query["sort"] = sort
|
|
292
|
+
|
|
293
|
+
yield from self.get_pages(
|
|
294
|
+
endpoint=endpoint,
|
|
295
|
+
data_key="data",
|
|
296
|
+
pagination_type=PaginationType.SEARCH,
|
|
297
|
+
search_query=search_query,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def transform_contact(contact: Dict[str, Any]) -> Dict[str, Any]:
|
|
302
|
+
"""
|
|
303
|
+
Transform a contact record to ensure consistent format.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
contact: Raw contact data from API
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Transformed contact data
|
|
310
|
+
"""
|
|
311
|
+
# Ensure consistent field names and types
|
|
312
|
+
transformed = contact.copy()
|
|
313
|
+
|
|
314
|
+
# Flatten location data if present
|
|
315
|
+
if "location" in transformed and isinstance(transformed["location"], dict):
|
|
316
|
+
location = transformed.pop("location")
|
|
317
|
+
transformed["location_country"] = location.get("country")
|
|
318
|
+
transformed["location_region"] = location.get("region")
|
|
319
|
+
transformed["location_city"] = location.get("city")
|
|
320
|
+
|
|
321
|
+
# Flatten companies relationship
|
|
322
|
+
if "companies" in transformed and isinstance(transformed["companies"], dict):
|
|
323
|
+
companies_data = transformed["companies"].get("data", [])
|
|
324
|
+
transformed["company_ids"] = [
|
|
325
|
+
c.get("id") for c in companies_data if c.get("id")
|
|
326
|
+
]
|
|
327
|
+
transformed["companies_count"] = len(companies_data)
|
|
328
|
+
|
|
329
|
+
# Ensure custom_attributes is always a dict
|
|
330
|
+
if "custom_attributes" not in transformed:
|
|
331
|
+
transformed["custom_attributes"] = {}
|
|
332
|
+
|
|
333
|
+
return transformed
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def transform_company(company: Dict[str, Any]) -> Dict[str, Any]:
|
|
337
|
+
"""
|
|
338
|
+
Transform a company record to ensure consistent format.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
company: Raw company data from API
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Transformed company data
|
|
345
|
+
"""
|
|
346
|
+
transformed = company.copy()
|
|
347
|
+
|
|
348
|
+
# Ensure custom_attributes is always a dict
|
|
349
|
+
if "custom_attributes" not in transformed:
|
|
350
|
+
transformed["custom_attributes"] = {}
|
|
351
|
+
|
|
352
|
+
# Flatten plan information if it's an object
|
|
353
|
+
if "plan" in transformed and isinstance(transformed["plan"], dict):
|
|
354
|
+
plan = transformed.pop("plan")
|
|
355
|
+
transformed["plan_id"] = plan.get("id")
|
|
356
|
+
transformed["plan_name"] = plan.get("name")
|
|
357
|
+
|
|
358
|
+
return transformed
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def transform_conversation(conversation: Dict[str, Any]) -> Dict[str, Any]:
|
|
362
|
+
"""
|
|
363
|
+
Transform a conversation record to ensure consistent format.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
conversation: Raw conversation data from API
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Transformed conversation data
|
|
370
|
+
"""
|
|
371
|
+
transformed = conversation.copy()
|
|
372
|
+
|
|
373
|
+
# Extract statistics if present
|
|
374
|
+
if "statistics" in transformed and isinstance(transformed["statistics"], dict):
|
|
375
|
+
stats = transformed.pop("statistics")
|
|
376
|
+
transformed["first_contact_reply_at"] = stats.get("first_contact_reply_at")
|
|
377
|
+
transformed["first_admin_reply_at"] = stats.get("first_admin_reply_at")
|
|
378
|
+
transformed["last_contact_reply_at"] = stats.get("last_contact_reply_at")
|
|
379
|
+
transformed["last_admin_reply_at"] = stats.get("last_admin_reply_at")
|
|
380
|
+
transformed["median_admin_reply_time"] = stats.get("median_admin_reply_time")
|
|
381
|
+
transformed["mean_admin_reply_time"] = stats.get("mean_admin_reply_time")
|
|
382
|
+
|
|
383
|
+
# Flatten conversation parts count
|
|
384
|
+
if "conversation_parts" in transformed and isinstance(
|
|
385
|
+
transformed["conversation_parts"], dict
|
|
386
|
+
):
|
|
387
|
+
parts = transformed["conversation_parts"]
|
|
388
|
+
transformed["conversation_parts_count"] = parts.get("total_count", 0)
|
|
389
|
+
|
|
390
|
+
return transformed
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def convert_datetime_to_timestamp(dt_obj: Any) -> int:
|
|
394
|
+
"""
|
|
395
|
+
Convert datetime object to Unix timestamp for Intercom API compatibility.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
dt_obj: DateTime object (pendulum or datetime)
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Unix timestamp as integer
|
|
402
|
+
"""
|
|
403
|
+
if hasattr(dt_obj, "int_timestamp"):
|
|
404
|
+
return dt_obj.int_timestamp
|
|
405
|
+
elif hasattr(dt_obj, "timestamp"):
|
|
406
|
+
return int(dt_obj.timestamp())
|
|
407
|
+
else:
|
|
408
|
+
raise ValueError(f"Cannot convert {type(dt_obj)} to timestamp")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def create_search_resource(
|
|
412
|
+
api_client: "IntercomAPIClient",
|
|
413
|
+
resource_name: str,
|
|
414
|
+
updated_at_incremental: Any,
|
|
415
|
+
transform_func: Optional[Callable] = None,
|
|
416
|
+
) -> Iterator[TDataItems]:
|
|
417
|
+
"""
|
|
418
|
+
Generic function for search-based incremental resources.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
api_client: Intercom API client
|
|
422
|
+
resource_name: Name of the resource (contacts, conversations)
|
|
423
|
+
updated_at_incremental: DLT incremental object
|
|
424
|
+
transform_func: Optional transformation function
|
|
425
|
+
|
|
426
|
+
Yields:
|
|
427
|
+
Transformed resource records
|
|
428
|
+
"""
|
|
429
|
+
query = build_incremental_query(
|
|
430
|
+
"updated_at",
|
|
431
|
+
updated_at_incremental.last_value,
|
|
432
|
+
updated_at_incremental.end_value,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
for page in api_client.search(resource_name, query):
|
|
436
|
+
if transform_func:
|
|
437
|
+
transformed_items = [transform_func(item) for item in page]
|
|
438
|
+
yield transformed_items
|
|
439
|
+
else:
|
|
440
|
+
yield page
|
|
441
|
+
|
|
442
|
+
if updated_at_incremental.end_out_of_range:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def create_tickets_resource(
|
|
447
|
+
api_client: "IntercomAPIClient",
|
|
448
|
+
updated_at_incremental: Any,
|
|
449
|
+
) -> Iterator[TDataItems]:
|
|
450
|
+
"""
|
|
451
|
+
Special function for tickets resource with updated_since parameter.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
api_client: Intercom API client
|
|
455
|
+
updated_at_incremental: DLT incremental object
|
|
456
|
+
|
|
457
|
+
Yields:
|
|
458
|
+
Filtered ticket records
|
|
459
|
+
"""
|
|
460
|
+
params = {"updated_since": updated_at_incremental.last_value}
|
|
461
|
+
|
|
462
|
+
end_timestamp = (
|
|
463
|
+
updated_at_incremental.end_value if updated_at_incremental.end_value else None
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
for page in api_client.get_pages(
|
|
467
|
+
"/tickets", "tickets", PaginationType.CURSOR, params=params
|
|
468
|
+
):
|
|
469
|
+
if end_timestamp:
|
|
470
|
+
filtered_tickets = [
|
|
471
|
+
t for t in page if t.get("updated_at", 0) <= end_timestamp
|
|
472
|
+
]
|
|
473
|
+
if filtered_tickets:
|
|
474
|
+
yield filtered_tickets
|
|
475
|
+
|
|
476
|
+
if any(t.get("updated_at", 0) > end_timestamp for t in page):
|
|
477
|
+
return
|
|
478
|
+
else:
|
|
479
|
+
yield page
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def create_pagination_resource(
|
|
483
|
+
api_client: "IntercomAPIClient",
|
|
484
|
+
endpoint: str,
|
|
485
|
+
data_key: str,
|
|
486
|
+
pagination_type: PaginationType,
|
|
487
|
+
updated_at_incremental: Any,
|
|
488
|
+
transform_func: Optional[Callable] = None,
|
|
489
|
+
params: Optional[Dict[str, Any]] = None,
|
|
490
|
+
) -> Iterator[TDataItems]:
|
|
491
|
+
"""
|
|
492
|
+
Generic function for cursor/simple pagination with client-side filtering.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
api_client: Intercom API client
|
|
496
|
+
endpoint: API endpoint path
|
|
497
|
+
data_key: Key in response containing data
|
|
498
|
+
pagination_type: Type of pagination
|
|
499
|
+
updated_at_incremental: DLT incremental object
|
|
500
|
+
transform_func: Optional transformation function
|
|
501
|
+
params: Additional query parameters
|
|
502
|
+
|
|
503
|
+
Yields:
|
|
504
|
+
Filtered and transformed resource records
|
|
505
|
+
"""
|
|
506
|
+
for page in api_client.get_pages(
|
|
507
|
+
endpoint, data_key, pagination_type, params=params
|
|
508
|
+
):
|
|
509
|
+
filtered_items = []
|
|
510
|
+
for item in page:
|
|
511
|
+
item_updated = item.get("updated_at", 0)
|
|
512
|
+
if item_updated >= updated_at_incremental.last_value:
|
|
513
|
+
if (
|
|
514
|
+
updated_at_incremental.end_value
|
|
515
|
+
and item_updated > updated_at_incremental.end_value
|
|
516
|
+
):
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
if transform_func:
|
|
520
|
+
filtered_items.append(transform_func(item))
|
|
521
|
+
else:
|
|
522
|
+
filtered_items.append(item)
|
|
523
|
+
|
|
524
|
+
if filtered_items:
|
|
525
|
+
yield filtered_items
|
|
526
|
+
|
|
527
|
+
if updated_at_incremental.end_out_of_range:
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def create_resource_from_config(
|
|
532
|
+
resource_name: str,
|
|
533
|
+
config: Dict[str, Any],
|
|
534
|
+
api_client: "IntercomAPIClient",
|
|
535
|
+
start_timestamp: int,
|
|
536
|
+
end_timestamp: Optional[int],
|
|
537
|
+
transform_functions: Dict[str, Callable],
|
|
538
|
+
) -> Any:
|
|
539
|
+
"""
|
|
540
|
+
Create a DLT resource from configuration.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
resource_name: Name of the resource
|
|
544
|
+
config: Resource configuration dict
|
|
545
|
+
api_client: Intercom API client
|
|
546
|
+
start_timestamp: Start timestamp for incremental loading
|
|
547
|
+
end_timestamp: End timestamp for incremental loading
|
|
548
|
+
transform_functions: Dict mapping transform function names to actual functions
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
DLT resource function
|
|
552
|
+
"""
|
|
553
|
+
import dlt
|
|
554
|
+
|
|
555
|
+
# Determine write disposition
|
|
556
|
+
write_disposition = "merge" if config["incremental"] else "replace"
|
|
557
|
+
|
|
558
|
+
# Get transform function if specified
|
|
559
|
+
transform_func = None
|
|
560
|
+
if config.get("transform_func"):
|
|
561
|
+
transform_func = transform_functions.get(config["transform_func"])
|
|
562
|
+
|
|
563
|
+
def resource_function(
|
|
564
|
+
updated_at: Optional[dlt.sources.incremental[int]] = dlt.sources.incremental(
|
|
565
|
+
"updated_at",
|
|
566
|
+
initial_value=start_timestamp,
|
|
567
|
+
end_value=end_timestamp,
|
|
568
|
+
allow_external_schedulers=True,
|
|
569
|
+
)
|
|
570
|
+
if config["incremental"]
|
|
571
|
+
else None,
|
|
572
|
+
) -> Iterator[TDataItems]:
|
|
573
|
+
"""
|
|
574
|
+
Auto-generated resource function.
|
|
575
|
+
"""
|
|
576
|
+
resource_type = config["type"]
|
|
577
|
+
|
|
578
|
+
if resource_type == "search":
|
|
579
|
+
yield from create_search_resource(
|
|
580
|
+
api_client, resource_name, updated_at, transform_func
|
|
581
|
+
)
|
|
582
|
+
elif resource_type == "pagination":
|
|
583
|
+
yield from create_pagination_resource(
|
|
584
|
+
api_client,
|
|
585
|
+
config["endpoint"],
|
|
586
|
+
config["data_key"],
|
|
587
|
+
getattr(PaginationType, config["pagination_type"].upper()),
|
|
588
|
+
updated_at,
|
|
589
|
+
transform_func,
|
|
590
|
+
config.get("params"),
|
|
591
|
+
)
|
|
592
|
+
elif resource_type == "tickets":
|
|
593
|
+
yield from create_tickets_resource(api_client, updated_at)
|
|
594
|
+
elif resource_type == "simple":
|
|
595
|
+
# Non-incremental resources
|
|
596
|
+
yield from api_client.get_pages(
|
|
597
|
+
config["endpoint"],
|
|
598
|
+
config["data_key"],
|
|
599
|
+
getattr(PaginationType, config["pagination_type"].upper()),
|
|
600
|
+
)
|
|
601
|
+
else:
|
|
602
|
+
raise ValueError(f"Unknown resource type: {resource_type}")
|
|
603
|
+
|
|
604
|
+
# For non-incremental resources, we need to return a function without parameters
|
|
605
|
+
if not config["incremental"]:
|
|
606
|
+
|
|
607
|
+
@dlt.resource(
|
|
608
|
+
name=resource_name,
|
|
609
|
+
primary_key="id",
|
|
610
|
+
write_disposition="replace",
|
|
611
|
+
columns=config.get("columns", {}),
|
|
612
|
+
)
|
|
613
|
+
def simple_resource_function() -> Iterator[TDataItems]:
|
|
614
|
+
"""
|
|
615
|
+
Auto-generated simple resource function.
|
|
616
|
+
"""
|
|
617
|
+
yield from api_client.get_pages(
|
|
618
|
+
config["endpoint"],
|
|
619
|
+
config["data_key"],
|
|
620
|
+
getattr(PaginationType, config["pagination_type"].upper()),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
return simple_resource_function
|
|
624
|
+
|
|
625
|
+
# Apply the decorator to the function
|
|
626
|
+
return dlt.resource( # type: ignore[call-overload]
|
|
627
|
+
resource_function,
|
|
628
|
+
name=resource_name,
|
|
629
|
+
primary_key="id",
|
|
630
|
+
write_disposition=write_disposition,
|
|
631
|
+
columns=config.get("columns", {}),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def build_incremental_query(
|
|
636
|
+
field: str,
|
|
637
|
+
start_value: Any,
|
|
638
|
+
end_value: Optional[Any] = None,
|
|
639
|
+
) -> Dict[str, Any]:
|
|
640
|
+
"""
|
|
641
|
+
Build a search query for incremental loading.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
field: Field to filter on
|
|
645
|
+
start_value: Start value (inclusive)
|
|
646
|
+
end_value: Optional end value (inclusive)
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Query dict for Intercom Search API
|
|
650
|
+
"""
|
|
651
|
+
conditions = [
|
|
652
|
+
{
|
|
653
|
+
"field": field,
|
|
654
|
+
"operator": ">",
|
|
655
|
+
"value": start_value,
|
|
656
|
+
}
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
if end_value is not None:
|
|
660
|
+
conditions.append(
|
|
661
|
+
{
|
|
662
|
+
"field": field,
|
|
663
|
+
"operator": "<",
|
|
664
|
+
"value": end_value,
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if len(conditions) == 1:
|
|
669
|
+
return conditions[0]
|
|
670
|
+
else:
|
|
671
|
+
return {
|
|
672
|
+
"operator": "AND",
|
|
673
|
+
"value": conditions,
|
|
674
|
+
}
|