omniload 0.0.0.dev0__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 (218) hide show
  1. omniload/conftest.py +72 -0
  2. omniload/main.py +810 -0
  3. omniload/src/.gitignore +10 -0
  4. omniload/src/adjust/__init__.py +108 -0
  5. omniload/src/adjust/adjust_helpers.py +122 -0
  6. omniload/src/airtable/__init__.py +84 -0
  7. omniload/src/allium/__init__.py +128 -0
  8. omniload/src/anthropic/__init__.py +277 -0
  9. omniload/src/anthropic/helpers.py +525 -0
  10. omniload/src/applovin/__init__.py +316 -0
  11. omniload/src/applovin_max/__init__.py +117 -0
  12. omniload/src/appsflyer/__init__.py +325 -0
  13. omniload/src/appsflyer/client.py +110 -0
  14. omniload/src/appstore/__init__.py +142 -0
  15. omniload/src/appstore/client.py +126 -0
  16. omniload/src/appstore/errors.py +15 -0
  17. omniload/src/appstore/models.py +117 -0
  18. omniload/src/appstore/resources.py +179 -0
  19. omniload/src/arrow/__init__.py +81 -0
  20. omniload/src/asana_source/__init__.py +281 -0
  21. omniload/src/asana_source/helpers.py +30 -0
  22. omniload/src/asana_source/settings.py +158 -0
  23. omniload/src/attio/__init__.py +102 -0
  24. omniload/src/attio/helpers.py +65 -0
  25. omniload/src/blob.py +95 -0
  26. omniload/src/bruin/__init__.py +76 -0
  27. omniload/src/chess/__init__.py +180 -0
  28. omniload/src/chess/helpers.py +35 -0
  29. omniload/src/chess/settings.py +18 -0
  30. omniload/src/clickup/__init__.py +85 -0
  31. omniload/src/clickup/helpers.py +47 -0
  32. omniload/src/collector/spinner.py +43 -0
  33. omniload/src/couchbase_source/__init__.py +118 -0
  34. omniload/src/couchbase_source/helpers.py +135 -0
  35. omniload/src/cursor/__init__.py +83 -0
  36. omniload/src/cursor/helpers.py +188 -0
  37. omniload/src/customer_io/__init__.py +486 -0
  38. omniload/src/customer_io/helpers.py +530 -0
  39. omniload/src/destinations.py +982 -0
  40. omniload/src/docebo/__init__.py +589 -0
  41. omniload/src/docebo/client.py +435 -0
  42. omniload/src/docebo/helpers.py +97 -0
  43. omniload/src/dune/__init__.py +104 -0
  44. omniload/src/dune/helpers.py +108 -0
  45. omniload/src/dynamodb/__init__.py +86 -0
  46. omniload/src/elasticsearch/__init__.py +80 -0
  47. omniload/src/elasticsearch/helpers.py +141 -0
  48. omniload/src/errors.py +26 -0
  49. omniload/src/facebook_ads/__init__.py +403 -0
  50. omniload/src/facebook_ads/exceptions.py +19 -0
  51. omniload/src/facebook_ads/helpers.py +296 -0
  52. omniload/src/facebook_ads/settings.py +224 -0
  53. omniload/src/facebook_ads/utils.py +53 -0
  54. omniload/src/factory.py +305 -0
  55. omniload/src/filesystem/__init__.py +133 -0
  56. omniload/src/filesystem/helpers.py +114 -0
  57. omniload/src/filesystem/readers.py +187 -0
  58. omniload/src/filters.py +62 -0
  59. omniload/src/fireflies/__init__.py +151 -0
  60. omniload/src/fireflies/helpers.py +753 -0
  61. omniload/src/fluxx/__init__.py +10013 -0
  62. omniload/src/fluxx/helpers.py +233 -0
  63. omniload/src/frankfurter/__init__.py +157 -0
  64. omniload/src/frankfurter/helpers.py +48 -0
  65. omniload/src/freshdesk/__init__.py +103 -0
  66. omniload/src/freshdesk/freshdesk_client.py +151 -0
  67. omniload/src/freshdesk/settings.py +23 -0
  68. omniload/src/fundraiseup/__init__.py +95 -0
  69. omniload/src/fundraiseup/client.py +81 -0
  70. omniload/src/github/__init__.py +202 -0
  71. omniload/src/github/helpers.py +207 -0
  72. omniload/src/github/queries.py +129 -0
  73. omniload/src/github/settings.py +24 -0
  74. omniload/src/google_ads/__init__.py +198 -0
  75. omniload/src/google_ads/field.py +17 -0
  76. omniload/src/google_ads/metrics.py +254 -0
  77. omniload/src/google_ads/predicates.py +37 -0
  78. omniload/src/google_ads/reports.py +411 -0
  79. omniload/src/google_ads/test_google_ads.py +184 -0
  80. omniload/src/google_analytics/__init__.py +144 -0
  81. omniload/src/google_analytics/helpers.py +312 -0
  82. omniload/src/google_sheets/README.md +95 -0
  83. omniload/src/google_sheets/__init__.py +166 -0
  84. omniload/src/google_sheets/helpers/__init__.py +15 -0
  85. omniload/src/google_sheets/helpers/api_calls.py +160 -0
  86. omniload/src/google_sheets/helpers/data_processing.py +316 -0
  87. omniload/src/gorgias/__init__.py +595 -0
  88. omniload/src/gorgias/helpers.py +166 -0
  89. omniload/src/hostaway/__init__.py +302 -0
  90. omniload/src/hostaway/client.py +288 -0
  91. omniload/src/http/__init__.py +38 -0
  92. omniload/src/http/readers.py +146 -0
  93. omniload/src/http_client.py +24 -0
  94. omniload/src/hubspot/__init__.py +800 -0
  95. omniload/src/hubspot/helpers.py +417 -0
  96. omniload/src/hubspot/settings.py +329 -0
  97. omniload/src/indeed/__init__.py +153 -0
  98. omniload/src/indeed/helpers.py +228 -0
  99. omniload/src/influxdb/__init__.py +46 -0
  100. omniload/src/influxdb/client.py +34 -0
  101. omniload/src/intercom/__init__.py +142 -0
  102. omniload/src/intercom/helpers.py +674 -0
  103. omniload/src/intercom/settings.py +279 -0
  104. omniload/src/isoc_pulse/__init__.py +159 -0
  105. omniload/src/jira_source/__init__.py +377 -0
  106. omniload/src/jira_source/helpers.py +510 -0
  107. omniload/src/jira_source/settings.py +184 -0
  108. omniload/src/kafka/__init__.py +120 -0
  109. omniload/src/kafka/helpers.py +241 -0
  110. omniload/src/kinesis/__init__.py +153 -0
  111. omniload/src/kinesis/helpers.py +96 -0
  112. omniload/src/klaviyo/__init__.py +237 -0
  113. omniload/src/klaviyo/client.py +212 -0
  114. omniload/src/klaviyo/helpers.py +19 -0
  115. omniload/src/linear/__init__.py +634 -0
  116. omniload/src/linear/helpers.py +111 -0
  117. omniload/src/linkedin_ads/__init__.py +266 -0
  118. omniload/src/linkedin_ads/dimension_time_enum.py +17 -0
  119. omniload/src/linkedin_ads/helpers.py +246 -0
  120. omniload/src/loader.py +69 -0
  121. omniload/src/mailchimp/__init__.py +126 -0
  122. omniload/src/mailchimp/helpers.py +226 -0
  123. omniload/src/mailchimp/settings.py +164 -0
  124. omniload/src/masking.py +344 -0
  125. omniload/src/mixpanel/__init__.py +62 -0
  126. omniload/src/mixpanel/client.py +104 -0
  127. omniload/src/monday/__init__.py +246 -0
  128. omniload/src/monday/helpers.py +392 -0
  129. omniload/src/monday/settings.py +325 -0
  130. omniload/src/mongodb/__init__.py +281 -0
  131. omniload/src/mongodb/helpers.py +975 -0
  132. omniload/src/notion/__init__.py +69 -0
  133. omniload/src/notion/helpers/__init__.py +14 -0
  134. omniload/src/notion/helpers/client.py +178 -0
  135. omniload/src/notion/helpers/database.py +92 -0
  136. omniload/src/notion/settings.py +17 -0
  137. omniload/src/partition.py +32 -0
  138. omniload/src/personio/__init__.py +345 -0
  139. omniload/src/personio/helpers.py +100 -0
  140. omniload/src/phantombuster/__init__.py +65 -0
  141. omniload/src/phantombuster/client.py +87 -0
  142. omniload/src/pinterest/__init__.py +82 -0
  143. omniload/src/pipedrive/__init__.py +212 -0
  144. omniload/src/pipedrive/helpers/__init__.py +37 -0
  145. omniload/src/pipedrive/helpers/custom_fields_munger.py +116 -0
  146. omniload/src/pipedrive/helpers/pages.py +129 -0
  147. omniload/src/pipedrive/settings.py +41 -0
  148. omniload/src/pipedrive/typing.py +17 -0
  149. omniload/src/plusvibeai/__init__.py +335 -0
  150. omniload/src/plusvibeai/helpers.py +544 -0
  151. omniload/src/plusvibeai/settings.py +252 -0
  152. omniload/src/primer/__init__.py +45 -0
  153. omniload/src/primer/helpers.py +79 -0
  154. omniload/src/quickbooks/__init__.py +117 -0
  155. omniload/src/reddit_ads/__init__.py +183 -0
  156. omniload/src/reddit_ads/helpers.py +232 -0
  157. omniload/src/resource.py +40 -0
  158. omniload/src/revenuecat/__init__.py +83 -0
  159. omniload/src/revenuecat/helpers.py +237 -0
  160. omniload/src/salesforce/__init__.py +170 -0
  161. omniload/src/salesforce/helpers.py +78 -0
  162. omniload/src/shopify/__init__.py +1953 -0
  163. omniload/src/shopify/exceptions.py +17 -0
  164. omniload/src/shopify/helpers.py +202 -0
  165. omniload/src/shopify/settings.py +19 -0
  166. omniload/src/slack/__init__.py +290 -0
  167. omniload/src/slack/helpers.py +218 -0
  168. omniload/src/slack/settings.py +36 -0
  169. omniload/src/smartsheets/__init__.py +82 -0
  170. omniload/src/snapchat_ads/__init__.py +455 -0
  171. omniload/src/snapchat_ads/client.py +72 -0
  172. omniload/src/snapchat_ads/helpers.py +630 -0
  173. omniload/src/snapchat_ads/settings.py +130 -0
  174. omniload/src/socrata_source/__init__.py +83 -0
  175. omniload/src/socrata_source/helpers.py +85 -0
  176. omniload/src/socrata_source/settings.py +8 -0
  177. omniload/src/solidgate/__init__.py +219 -0
  178. omniload/src/solidgate/helpers.py +154 -0
  179. omniload/src/sources.py +5408 -0
  180. omniload/src/sql_database/__init__.py +0 -0
  181. omniload/src/sql_database/callbacks.py +66 -0
  182. omniload/src/stripe_analytics/__init__.py +183 -0
  183. omniload/src/stripe_analytics/helpers.py +386 -0
  184. omniload/src/stripe_analytics/settings.py +80 -0
  185. omniload/src/table_definition.py +15 -0
  186. omniload/src/testdata/fakebqcredentials.json +14 -0
  187. omniload/src/tiktok_ads/__init__.py +150 -0
  188. omniload/src/tiktok_ads/tiktok_helpers.py +130 -0
  189. omniload/src/time.py +11 -0
  190. omniload/src/trustpilot/__init__.py +48 -0
  191. omniload/src/trustpilot/client.py +48 -0
  192. omniload/src/version.py +6 -0
  193. omniload/src/wise/__init__.py +68 -0
  194. omniload/src/wise/client.py +63 -0
  195. omniload/src/zendesk/__init__.py +480 -0
  196. omniload/src/zendesk/helpers/__init__.py +39 -0
  197. omniload/src/zendesk/helpers/api_helpers.py +119 -0
  198. omniload/src/zendesk/helpers/credentials.py +68 -0
  199. omniload/src/zendesk/helpers/talk_api.py +132 -0
  200. omniload/src/zendesk/settings.py +71 -0
  201. omniload/src/zoom/__init__.py +99 -0
  202. omniload/src/zoom/helpers.py +102 -0
  203. omniload/testdata/.gitignore +2 -0
  204. omniload/testdata/create_replace.csv +21 -0
  205. omniload/testdata/delete_insert_expected.csv +6 -0
  206. omniload/testdata/delete_insert_part1.csv +5 -0
  207. omniload/testdata/delete_insert_part2.csv +6 -0
  208. omniload/testdata/merge_expected.csv +5 -0
  209. omniload/testdata/merge_part1.csv +4 -0
  210. omniload/testdata/merge_part2.csv +5 -0
  211. omniload/tests/unit/test_smartsheets.py +133 -0
  212. omniload-0.0.0.dev0.dist-info/METADATA +439 -0
  213. omniload-0.0.0.dev0.dist-info/RECORD +218 -0
  214. omniload-0.0.0.dev0.dist-info/WHEEL +4 -0
  215. omniload-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  216. omniload-0.0.0.dev0.dist-info/licenses/LICENSE.Apache-2.0 +201 -0
  217. omniload-0.0.0.dev0.dist-info/licenses/LICENSE.md +21 -0
  218. omniload-0.0.0.dev0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,232 @@
1
+ import time
2
+
3
+ import requests
4
+ from dlt.sources.helpers.requests import Client
5
+ from pendulum import Date
6
+
7
+ MONETARY_FIELDS = {"spend", "ecpm", "cpc"}
8
+
9
+ BASE_URL = "https://ads-api.reddit.com/api/v3"
10
+
11
+ LEVEL_ID_FIELDS = {
12
+ "ACCOUNT": "account_id",
13
+ "CAMPAIGN": "campaign_id",
14
+ "AD_GROUP": "ad_group_id",
15
+ "AD": "ad_id",
16
+ }
17
+
18
+ VALID_LEVELS = {"ACCOUNT", "CAMPAIGN", "AD_GROUP", "AD"}
19
+
20
+ VALID_BREAKDOWNS = {
21
+ "date",
22
+ "country",
23
+ "region",
24
+ "community",
25
+ "placement",
26
+ "device_os",
27
+ "gender",
28
+ "interest",
29
+ "keyword",
30
+ "carousel_card",
31
+ }
32
+
33
+ VALID_METRICS = {
34
+ "IMPRESSIONS",
35
+ "REACH",
36
+ "CLICKS",
37
+ "SPEND",
38
+ "ECPM",
39
+ "CTR",
40
+ "CPC",
41
+ "CONVERSIONS",
42
+ "CONVERSION_ROAS",
43
+ "TOTAL_ITEMS",
44
+ "TOTAL_VALUE",
45
+ "AVG_VALUE",
46
+ "REDDIT_LEADS",
47
+ "COMMENTS_PAGE_VIEWS",
48
+ "COMMENT_UPVOTES",
49
+ "COMMENT_DOWNVOTES",
50
+ "VIEWER_COMMENTS",
51
+ "VIDEO_STARTED",
52
+ "VIDEO_WATCHED_3_SECONDS",
53
+ "VIDEO_WATCHED_5_SECONDS",
54
+ "VIDEO_WATCHED_25_PERCENT",
55
+ "VIDEO_WATCHED_50_PERCENT",
56
+ "VIDEO_WATCHED_75_PERCENT",
57
+ "VIDEO_WATCHED_100_PERCENT",
58
+ "VIDEO_WATCHED_6_SECONDS_RATE",
59
+ "VIDEO_WATCHED_15_SECONDS_RATE",
60
+ }
61
+
62
+
63
+ def retry_on_limit(
64
+ response: requests.Response | None, exception: BaseException | None
65
+ ) -> bool:
66
+ if response is None:
67
+ return False
68
+ return response.status_code == 429
69
+
70
+
71
+ def create_client() -> requests.Session:
72
+ return Client(
73
+ raise_for_status=False,
74
+ retry_condition=retry_on_limit,
75
+ request_max_attempts=12,
76
+ request_backoff_factor=2,
77
+ ).session
78
+
79
+
80
+ def handle_rate_limit(response: requests.Response) -> None:
81
+ remaining = response.headers.get("X-RateLimit-Remaining")
82
+ reset = response.headers.get("X-RateLimit-Reset")
83
+ if remaining is not None and reset is not None:
84
+ try:
85
+ if float(remaining) < 2:
86
+ sleep_time = float(reset)
87
+ if sleep_time > 0:
88
+ time.sleep(sleep_time)
89
+ except (ValueError, TypeError):
90
+ pass
91
+
92
+
93
+ def convert_microcurrency(records: list[dict], metrics: list[str]) -> list[dict]:
94
+ monetary = MONETARY_FIELDS & {m.lower() for m in metrics}
95
+ if not monetary:
96
+ return records
97
+ for record in records:
98
+ for field in monetary:
99
+ if field in record and record[field] is not None:
100
+ record[field] = record[field] / 1_000_000
101
+ return records
102
+
103
+
104
+ def parse_custom_table(table: str) -> tuple[str, list[str], list[str]]:
105
+ parts = table.split(":")
106
+ if len(parts) != 3:
107
+ raise ValueError(
108
+ "Invalid custom table format. Expected: custom:<level>,<breakdowns>:<metrics>"
109
+ )
110
+
111
+ dimensions = [d.strip() for d in parts[1].split(",") if d.strip()]
112
+ if not dimensions:
113
+ raise ValueError("At least a level is required in the dimensions segment")
114
+
115
+ level = dimensions[0].upper()
116
+ if level not in VALID_LEVELS:
117
+ raise ValueError(
118
+ f"Invalid level '{level}'. Must be one of: {', '.join(sorted(VALID_LEVELS))}"
119
+ )
120
+
121
+ breakdowns = [b.lower() for b in dimensions[1:]]
122
+ if len(breakdowns) > 2:
123
+ raise ValueError("Reddit Ads supports at most 2 breakdowns per report")
124
+
125
+ for b in breakdowns:
126
+ if b not in VALID_BREAKDOWNS:
127
+ raise ValueError(
128
+ f"Invalid breakdown '{b}'. Must be one of: {', '.join(sorted(VALID_BREAKDOWNS))}"
129
+ )
130
+
131
+ metrics = [m.strip().upper() for m in parts[2].split(",") if m.strip()]
132
+ if not metrics:
133
+ raise ValueError("At least one metric is required")
134
+
135
+ for m in metrics:
136
+ if m not in VALID_METRICS:
137
+ raise ValueError(
138
+ f"Invalid metric '{m}'. Must be one of: {', '.join(sorted(VALID_METRICS))}"
139
+ )
140
+
141
+ return level, breakdowns, metrics
142
+
143
+
144
+ class RedditAdsAPI:
145
+ def __init__(self, access_token: str):
146
+ self.headers = {
147
+ "Authorization": f"Bearer {access_token}",
148
+ "User-Agent": "omniload/1.0",
149
+ }
150
+ self.client = create_client()
151
+
152
+ def fetch_pages(self, url: str, page_size: int = 100):
153
+ separator = "&" if "?" in url else "?"
154
+ paginated_url = f"{url}{separator}page_size={page_size}"
155
+
156
+ while True:
157
+ response = self.client.get(url=paginated_url, headers=self.headers)
158
+
159
+ if response.status_code != 200:
160
+ raise ValueError(
161
+ f"Reddit Ads API Error ({response.status_code}): {response.text}"
162
+ )
163
+
164
+ handle_rate_limit(response)
165
+
166
+ result = response.json()
167
+ elements = result.get("data", [])
168
+
169
+ if not elements:
170
+ break
171
+
172
+ yield elements
173
+
174
+ pagination = result.get("pagination", {})
175
+ next_url = pagination.get("next_url")
176
+ if not next_url:
177
+ break
178
+
179
+ paginated_url = next_url
180
+
181
+
182
+ class RedditAdsReportAPI:
183
+ def __init__(
184
+ self,
185
+ access_token: str,
186
+ account_ids: list[str],
187
+ level: str,
188
+ breakdowns: list[str],
189
+ metrics: list[str],
190
+ ):
191
+ self.headers = {
192
+ "Authorization": f"Bearer {access_token}",
193
+ "User-Agent": "omniload/1.0",
194
+ "Content-Type": "application/json",
195
+ }
196
+ self.account_ids = account_ids
197
+ self.level = level
198
+ self.breakdowns = breakdowns
199
+ self.metrics = metrics
200
+ self.client = create_client()
201
+
202
+ def fetch_report(self, start_date: Date, end_date: Date):
203
+ body = {
204
+ "start_date": start_date.to_date_string(),
205
+ "end_date": end_date.to_date_string(),
206
+ "level": self.level,
207
+ "metrics": self.metrics,
208
+ "breakdowns": self.breakdowns,
209
+ }
210
+
211
+ for account_id in self.account_ids:
212
+ url = f"{BASE_URL}/accounts/{account_id}/reports"
213
+ response = self.client.post(url=url, json=body, headers=self.headers)
214
+
215
+ if response.status_code != 200:
216
+ raise ValueError(
217
+ f"Reddit Ads Report API Error ({response.status_code}): {response.text}"
218
+ )
219
+
220
+ handle_rate_limit(response)
221
+
222
+ result = response.json()
223
+ records = result.get("data", [])
224
+
225
+ if not records:
226
+ continue
227
+
228
+ for record in records:
229
+ record["account_id"] = account_id
230
+
231
+ records = convert_microcurrency(records, self.metrics)
232
+ yield records
@@ -0,0 +1,40 @@
1
+ from typing import Callable
2
+
3
+ from dlt.sources import DltResource, DltSource
4
+
5
+
6
+ def for_each(
7
+ source: DltSource | DltResource, ex: Callable[[DltResource], None | DltResource]
8
+ ):
9
+ """
10
+ Apply a function to each resource in a source.
11
+ """
12
+ if hasattr(source, "selected_resources") and source.selected_resources:
13
+ resource_names = list(source.selected_resources.keys())
14
+ for res in resource_names:
15
+ ex(source.resources[res]) # type: ignore[union-attr]
16
+ else:
17
+ ex(source) # type: ignore[arg-type]
18
+
19
+
20
+ class TypeHintMap:
21
+ def __init__(self):
22
+ self.handled_typehints = False
23
+
24
+ def type_hint_map(self, item):
25
+ if self.handled_typehints:
26
+ return item
27
+
28
+ array_cols = []
29
+ for col in item:
30
+ if isinstance(item[col], (list, tuple)):
31
+ array_cols.append(col)
32
+ if array_cols:
33
+ import dlt
34
+
35
+ source = dlt.current.source()
36
+ columns = [{"name": col, "data_type": "json"} for col in array_cols]
37
+ for_each(source, lambda x: x.apply_hints(columns=columns))
38
+
39
+ self.handled_typehints = True
40
+ return item
@@ -0,0 +1,83 @@
1
+ from typing import Any, Dict, Iterable, Iterator
2
+
3
+ import aiohttp
4
+ import dlt
5
+
6
+ from .helpers import (
7
+ _make_request,
8
+ _paginate,
9
+ convert_timestamps_to_iso,
10
+ create_project_resource,
11
+ process_customer_with_nested_resources_async,
12
+ )
13
+
14
+
15
+ @dlt.source(name="revenuecat", max_table_nesting=0)
16
+ def revenuecat_source(
17
+ api_key: str,
18
+ project_id: str = None,
19
+ ) -> Iterable[dlt.sources.DltResource]:
20
+ """
21
+ RevenueCat source for extracting data from RevenueCat API v2.
22
+
23
+ Args:
24
+ api_key: RevenueCat API v2 secret key with Bearer token format
25
+ project_id: RevenueCat project ID (required for customers, products, entitlements, offerings, subscriptions, purchases)
26
+
27
+ Returns:
28
+ Iterable of DLT resources for customers, products, entitlements, offerings, purchases, subscriptions, and projects
29
+ """
30
+
31
+ @dlt.resource(name="projects", primary_key="id", write_disposition="merge")
32
+ def projects() -> Iterator[Dict[str, Any]]:
33
+ """Get list of projects."""
34
+ # Get projects list
35
+ data = _make_request(api_key, "/projects")
36
+ if "items" in data:
37
+ for project in data["items"]:
38
+ project = convert_timestamps_to_iso(project, ["created_at"])
39
+ yield project
40
+
41
+ @dlt.resource(
42
+ name="customer_ids",
43
+ write_disposition="replace",
44
+ selected=False,
45
+ parallelized=True,
46
+ )
47
+ def customer_ids():
48
+ if project_id is None:
49
+ raise ValueError("project_id is required for customers resource")
50
+
51
+ yield _paginate(api_key, f"/projects/{project_id}/customers")
52
+
53
+ @dlt.transformer(
54
+ data_from=customer_ids, write_disposition="replace", parallelized=True
55
+ )
56
+ async def customers(customers) -> Iterator[Dict[str, Any]]:
57
+ async with aiohttp.ClientSession() as session:
58
+ for customer in customers:
59
+ yield await process_customer_with_nested_resources_async(
60
+ session, api_key, project_id, customer
61
+ )
62
+
63
+ # Create project-dependent resources dynamically
64
+ project_resources = []
65
+ resource_names = ["products", "entitlements", "offerings"]
66
+
67
+ for resource_name in resource_names:
68
+
69
+ @dlt.resource(name=resource_name, primary_key="id", write_disposition="merge")
70
+ def create_resource(resource_name=resource_name) -> Iterator[Dict[str, Any]]:
71
+ """Get list of project resource."""
72
+ yield from create_project_resource(resource_name, api_key, project_id)
73
+
74
+ # Set the function name for better identification
75
+ create_resource.__name__ = resource_name
76
+ project_resources.append(create_resource)
77
+
78
+ return [
79
+ projects,
80
+ customer_ids,
81
+ customers,
82
+ *project_resources,
83
+ ]
@@ -0,0 +1,237 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any, Dict, Iterator, List, Optional
4
+
5
+ import aiohttp
6
+ import pendulum
7
+ import requests
8
+
9
+ REVENUECAT_API_BASE = "https://api.revenuecat.com/v2"
10
+
11
+
12
+ def _make_request(
13
+ api_key: str,
14
+ endpoint: str,
15
+ params: Optional[Dict[str, Any]] = None,
16
+ max_retries: int = 3,
17
+ ) -> Dict[str, Any]:
18
+ """Make a REST API request to RevenueCat API v2 with rate limiting."""
19
+ auth_header = f"Bearer {api_key}"
20
+
21
+ headers = {"Authorization": auth_header, "Content-Type": "application/json"}
22
+
23
+ url = f"{REVENUECAT_API_BASE}{endpoint}"
24
+
25
+ for attempt in range(max_retries + 1):
26
+ try:
27
+ response = requests.get(url, headers=headers, params=params or {})
28
+
29
+ # Handle rate limiting (429 Too Many Requests)
30
+ if response.status_code == 429:
31
+ if attempt < max_retries:
32
+ # Wait based on Retry-After header or exponential backoff
33
+ retry_after = response.headers.get("Retry-After")
34
+ if retry_after:
35
+ wait_time = int(retry_after)
36
+ else:
37
+ wait_time = (2**attempt) * 5 # 5, 10, 20 seconds
38
+
39
+ time.sleep(wait_time)
40
+ continue
41
+
42
+ response.raise_for_status()
43
+ return response.json()
44
+
45
+ except requests.exceptions.RequestException:
46
+ if attempt < max_retries:
47
+ wait_time = (2**attempt) * 2 # 2, 4, 8 seconds
48
+ time.sleep(wait_time)
49
+ continue
50
+ raise
51
+
52
+ # If we get here, all retries failed
53
+ response.raise_for_status()
54
+ return response.json()
55
+
56
+
57
+ def _paginate(
58
+ api_key: str, endpoint: str, params: Optional[Dict[str, Any]] = None
59
+ ) -> Iterator[Dict[str, Any]]:
60
+ """Paginate through RevenueCat API results."""
61
+ current_params = params.copy() if params is not None else {}
62
+ current_params["limit"] = 1000
63
+
64
+ while True:
65
+ data = _make_request(api_key, endpoint, current_params)
66
+
67
+ if "items" in data and data["items"] is not None:
68
+ yield data["items"]
69
+
70
+ if "next_page" not in data:
71
+ break
72
+
73
+ # Extract starting_after parameter from next_page URL
74
+ next_page_url = data["next_page"]
75
+ if next_page_url and "starting_after=" in next_page_url:
76
+ starting_after = next_page_url.split("starting_after=")[1].split("&")[0]
77
+ current_params["starting_after"] = starting_after
78
+ else:
79
+ break
80
+
81
+
82
+ def convert_timestamps_to_iso(
83
+ record: Dict[str, Any], timestamp_fields: List[str]
84
+ ) -> Dict[str, Any]:
85
+ """Convert timestamp fields from milliseconds to ISO format."""
86
+ for field in timestamp_fields:
87
+ if field in record and record[field] is not None:
88
+ timestamp_ms = record[field]
89
+ dt = pendulum.from_timestamp(timestamp_ms / 1000)
90
+ record[field] = dt.to_iso8601_string()
91
+
92
+ return record
93
+
94
+
95
+ async def _make_request_async(
96
+ session: aiohttp.ClientSession,
97
+ api_key: str,
98
+ endpoint: str,
99
+ params: Optional[Dict[str, Any]] = None,
100
+ max_retries: int = 3,
101
+ ) -> Dict[str, Any]:
102
+ """Make an async REST API request to RevenueCat API v2 with rate limiting."""
103
+ auth_header = f"Bearer {api_key}"
104
+
105
+ headers = {"Authorization": auth_header, "Content-Type": "application/json"}
106
+
107
+ url = f"{REVENUECAT_API_BASE}{endpoint}"
108
+
109
+ for attempt in range(max_retries + 1):
110
+ try:
111
+ async with session.get(
112
+ url, headers=headers, params=params or {}
113
+ ) as response:
114
+ # Handle rate limiting (429 Too Many Requests)
115
+ if response.status == 429:
116
+ if attempt < max_retries:
117
+ # Wait based on Retry-After header or exponential backoff
118
+ retry_after = response.headers.get("Retry-After")
119
+ if retry_after:
120
+ wait_time = int(retry_after)
121
+ else:
122
+ wait_time = (2**attempt) * 5 # 5, 10, 20 seconds
123
+
124
+ await asyncio.sleep(wait_time)
125
+ continue
126
+
127
+ response.raise_for_status()
128
+ return await response.json()
129
+
130
+ except aiohttp.ClientError:
131
+ if attempt < max_retries:
132
+ wait_time = (2**attempt) * 2 # 2, 4, 8 seconds
133
+ await asyncio.sleep(wait_time)
134
+ continue
135
+ raise
136
+
137
+ # If we get here, all retries failed
138
+ async with session.get(url, headers=headers, params=params or {}) as response:
139
+ response.raise_for_status()
140
+ return await response.json()
141
+
142
+
143
+ async def _paginate_async(
144
+ session: aiohttp.ClientSession,
145
+ api_key: str,
146
+ endpoint: str,
147
+ params: Optional[Dict[str, Any]] = None,
148
+ ) -> List[Dict[str, Any]]:
149
+ """Paginate through RevenueCat API results asynchronously."""
150
+ items = []
151
+ current_params = params.copy() if params is not None else {}
152
+ current_params["limit"] = 1000
153
+
154
+ while True:
155
+ data = await _make_request_async(session, api_key, endpoint, current_params)
156
+
157
+ # Collect items from the current page
158
+ if "items" in data and data["items"] is not None:
159
+ items.extend(data["items"])
160
+
161
+ # Check if there's a next page
162
+ if "next_page" not in data:
163
+ break
164
+
165
+ # Extract starting_after parameter from next_page URL
166
+ next_page_url = data["next_page"]
167
+ if next_page_url and "starting_after=" in next_page_url:
168
+ starting_after = next_page_url.split("starting_after=")[1].split("&")[0]
169
+ current_params["starting_after"] = starting_after
170
+ else:
171
+ break
172
+
173
+ return items
174
+
175
+
176
+ async def process_customer_with_nested_resources_async(
177
+ session: aiohttp.ClientSession,
178
+ api_key: str,
179
+ project_id: str,
180
+ customer: Dict[str, Any],
181
+ ) -> Dict[str, Any]:
182
+ customer_id = customer["id"]
183
+ customer = convert_timestamps_to_iso(customer, ["first_seen_at", "last_seen_at"])
184
+ nested_resources = [
185
+ ("subscriptions", ["purchased_at", "expires_at", "grace_period_expires_at"]),
186
+ ("purchases", ["purchased_at", "expires_at"]),
187
+ ]
188
+
189
+ async def fetch_and_convert(resource_name, timestamp_fields):
190
+ if resource_name not in customer or customer[resource_name] is None:
191
+ endpoint = f"/projects/{project_id}/customers/{customer_id}/{resource_name}"
192
+ customer[resource_name] = await _paginate_async(session, api_key, endpoint)
193
+ if (
194
+ timestamp_fields
195
+ and resource_name in customer
196
+ and customer[resource_name] is not None
197
+ ):
198
+ for item in customer[resource_name]:
199
+ convert_timestamps_to_iso(item, timestamp_fields)
200
+
201
+ await asyncio.gather(
202
+ *[
203
+ fetch_and_convert(resource_name, timestamp_fields)
204
+ for resource_name, timestamp_fields in nested_resources
205
+ ]
206
+ )
207
+
208
+ return customer
209
+
210
+
211
+ def create_project_resource(
212
+ resource_name: str,
213
+ api_key: str,
214
+ project_id: str = None,
215
+ timestamp_fields: List[str] = None,
216
+ ) -> Iterator[Dict[str, Any]]:
217
+ """
218
+ Helper function to create DLT resources for project-dependent endpoints.
219
+
220
+ Args:
221
+ resource_name: Name of the resource (e.g., 'products', 'entitlements', 'offerings')
222
+ api_key: RevenueCat API key
223
+ project_id: RevenueCat project ID
224
+ timestamp_fields: List of timestamp fields to convert to ISO format
225
+
226
+ Returns:
227
+ Iterator of resource data
228
+ """
229
+ if project_id is None:
230
+ raise ValueError(f"project_id is required for {resource_name} resource")
231
+
232
+ endpoint = f"/projects/{project_id}/{resource_name}"
233
+ default_timestamp_fields = timestamp_fields or ["created_at", "updated_at"]
234
+
235
+ for item in _paginate(api_key, endpoint):
236
+ item = convert_timestamps_to_iso(item, default_timestamp_fields)
237
+ yield item