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.
Files changed (146) hide show
  1. ingestr/conftest.py +72 -0
  2. ingestr/main.py +134 -87
  3. ingestr/src/adjust/__init__.py +4 -4
  4. ingestr/src/adjust/adjust_helpers.py +7 -3
  5. ingestr/src/airtable/__init__.py +3 -2
  6. ingestr/src/allium/__init__.py +128 -0
  7. ingestr/src/anthropic/__init__.py +277 -0
  8. ingestr/src/anthropic/helpers.py +525 -0
  9. ingestr/src/applovin/__init__.py +262 -0
  10. ingestr/src/applovin_max/__init__.py +117 -0
  11. ingestr/src/appsflyer/__init__.py +325 -0
  12. ingestr/src/appsflyer/client.py +49 -45
  13. ingestr/src/appstore/__init__.py +1 -0
  14. ingestr/src/arrow/__init__.py +9 -1
  15. ingestr/src/asana_source/__init__.py +1 -1
  16. ingestr/src/attio/__init__.py +102 -0
  17. ingestr/src/attio/helpers.py +65 -0
  18. ingestr/src/blob.py +38 -11
  19. ingestr/src/buildinfo.py +1 -0
  20. ingestr/src/chess/__init__.py +1 -1
  21. ingestr/src/clickup/__init__.py +85 -0
  22. ingestr/src/clickup/helpers.py +47 -0
  23. ingestr/src/collector/spinner.py +43 -0
  24. ingestr/src/couchbase_source/__init__.py +118 -0
  25. ingestr/src/couchbase_source/helpers.py +135 -0
  26. ingestr/src/cursor/__init__.py +83 -0
  27. ingestr/src/cursor/helpers.py +188 -0
  28. ingestr/src/destinations.py +520 -33
  29. ingestr/src/docebo/__init__.py +589 -0
  30. ingestr/src/docebo/client.py +435 -0
  31. ingestr/src/docebo/helpers.py +97 -0
  32. ingestr/src/elasticsearch/__init__.py +80 -0
  33. ingestr/src/elasticsearch/helpers.py +138 -0
  34. ingestr/src/errors.py +8 -0
  35. ingestr/src/facebook_ads/__init__.py +47 -28
  36. ingestr/src/facebook_ads/helpers.py +59 -37
  37. ingestr/src/facebook_ads/settings.py +2 -0
  38. ingestr/src/facebook_ads/utils.py +39 -0
  39. ingestr/src/factory.py +116 -2
  40. ingestr/src/filesystem/__init__.py +8 -3
  41. ingestr/src/filters.py +46 -3
  42. ingestr/src/fluxx/__init__.py +9906 -0
  43. ingestr/src/fluxx/helpers.py +209 -0
  44. ingestr/src/frankfurter/__init__.py +157 -0
  45. ingestr/src/frankfurter/helpers.py +48 -0
  46. ingestr/src/freshdesk/__init__.py +89 -0
  47. ingestr/src/freshdesk/freshdesk_client.py +137 -0
  48. ingestr/src/freshdesk/settings.py +9 -0
  49. ingestr/src/fundraiseup/__init__.py +95 -0
  50. ingestr/src/fundraiseup/client.py +81 -0
  51. ingestr/src/github/__init__.py +41 -6
  52. ingestr/src/github/helpers.py +5 -5
  53. ingestr/src/google_analytics/__init__.py +22 -4
  54. ingestr/src/google_analytics/helpers.py +124 -6
  55. ingestr/src/google_sheets/__init__.py +4 -4
  56. ingestr/src/google_sheets/helpers/data_processing.py +2 -2
  57. ingestr/src/hostaway/__init__.py +302 -0
  58. ingestr/src/hostaway/client.py +288 -0
  59. ingestr/src/http/__init__.py +35 -0
  60. ingestr/src/http/readers.py +114 -0
  61. ingestr/src/http_client.py +24 -0
  62. ingestr/src/hubspot/__init__.py +66 -23
  63. ingestr/src/hubspot/helpers.py +52 -22
  64. ingestr/src/hubspot/settings.py +14 -7
  65. ingestr/src/influxdb/__init__.py +46 -0
  66. ingestr/src/influxdb/client.py +34 -0
  67. ingestr/src/intercom/__init__.py +142 -0
  68. ingestr/src/intercom/helpers.py +674 -0
  69. ingestr/src/intercom/settings.py +279 -0
  70. ingestr/src/isoc_pulse/__init__.py +159 -0
  71. ingestr/src/jira_source/__init__.py +340 -0
  72. ingestr/src/jira_source/helpers.py +439 -0
  73. ingestr/src/jira_source/settings.py +170 -0
  74. ingestr/src/kafka/__init__.py +4 -1
  75. ingestr/src/kinesis/__init__.py +139 -0
  76. ingestr/src/kinesis/helpers.py +82 -0
  77. ingestr/src/klaviyo/{_init_.py → __init__.py} +5 -6
  78. ingestr/src/linear/__init__.py +634 -0
  79. ingestr/src/linear/helpers.py +111 -0
  80. ingestr/src/linkedin_ads/helpers.py +0 -1
  81. ingestr/src/loader.py +69 -0
  82. ingestr/src/mailchimp/__init__.py +126 -0
  83. ingestr/src/mailchimp/helpers.py +226 -0
  84. ingestr/src/mailchimp/settings.py +164 -0
  85. ingestr/src/masking.py +344 -0
  86. ingestr/src/mixpanel/__init__.py +62 -0
  87. ingestr/src/mixpanel/client.py +99 -0
  88. ingestr/src/monday/__init__.py +246 -0
  89. ingestr/src/monday/helpers.py +392 -0
  90. ingestr/src/monday/settings.py +328 -0
  91. ingestr/src/mongodb/__init__.py +72 -8
  92. ingestr/src/mongodb/helpers.py +915 -38
  93. ingestr/src/partition.py +32 -0
  94. ingestr/src/personio/__init__.py +331 -0
  95. ingestr/src/personio/helpers.py +86 -0
  96. ingestr/src/phantombuster/__init__.py +65 -0
  97. ingestr/src/phantombuster/client.py +87 -0
  98. ingestr/src/pinterest/__init__.py +82 -0
  99. ingestr/src/pipedrive/__init__.py +198 -0
  100. ingestr/src/pipedrive/helpers/__init__.py +23 -0
  101. ingestr/src/pipedrive/helpers/custom_fields_munger.py +102 -0
  102. ingestr/src/pipedrive/helpers/pages.py +115 -0
  103. ingestr/src/pipedrive/settings.py +27 -0
  104. ingestr/src/pipedrive/typing.py +3 -0
  105. ingestr/src/plusvibeai/__init__.py +335 -0
  106. ingestr/src/plusvibeai/helpers.py +544 -0
  107. ingestr/src/plusvibeai/settings.py +252 -0
  108. ingestr/src/quickbooks/__init__.py +117 -0
  109. ingestr/src/resource.py +40 -0
  110. ingestr/src/revenuecat/__init__.py +83 -0
  111. ingestr/src/revenuecat/helpers.py +237 -0
  112. ingestr/src/salesforce/__init__.py +156 -0
  113. ingestr/src/salesforce/helpers.py +64 -0
  114. ingestr/src/shopify/__init__.py +1 -17
  115. ingestr/src/smartsheets/__init__.py +82 -0
  116. ingestr/src/snapchat_ads/__init__.py +489 -0
  117. ingestr/src/snapchat_ads/client.py +72 -0
  118. ingestr/src/snapchat_ads/helpers.py +535 -0
  119. ingestr/src/socrata_source/__init__.py +83 -0
  120. ingestr/src/socrata_source/helpers.py +85 -0
  121. ingestr/src/socrata_source/settings.py +8 -0
  122. ingestr/src/solidgate/__init__.py +219 -0
  123. ingestr/src/solidgate/helpers.py +154 -0
  124. ingestr/src/sources.py +3132 -212
  125. ingestr/src/stripe_analytics/__init__.py +49 -21
  126. ingestr/src/stripe_analytics/helpers.py +286 -1
  127. ingestr/src/stripe_analytics/settings.py +62 -10
  128. ingestr/src/telemetry/event.py +10 -9
  129. ingestr/src/tiktok_ads/__init__.py +12 -6
  130. ingestr/src/tiktok_ads/tiktok_helpers.py +0 -1
  131. ingestr/src/trustpilot/__init__.py +48 -0
  132. ingestr/src/trustpilot/client.py +48 -0
  133. ingestr/src/version.py +6 -1
  134. ingestr/src/wise/__init__.py +68 -0
  135. ingestr/src/wise/client.py +63 -0
  136. ingestr/src/zoom/__init__.py +99 -0
  137. ingestr/src/zoom/helpers.py +102 -0
  138. ingestr/tests/unit/test_smartsheets.py +133 -0
  139. ingestr-0.14.104.dist-info/METADATA +563 -0
  140. ingestr-0.14.104.dist-info/RECORD +203 -0
  141. ingestr/src/appsflyer/_init_.py +0 -24
  142. ingestr-0.13.2.dist-info/METADATA +0 -302
  143. ingestr-0.13.2.dist-info/RECORD +0 -107
  144. {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/WHEEL +0 -0
  145. {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/entry_points.txt +0 -0
  146. {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
+ }