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.
- omniload/conftest.py +72 -0
- omniload/main.py +810 -0
- omniload/src/.gitignore +10 -0
- omniload/src/adjust/__init__.py +108 -0
- omniload/src/adjust/adjust_helpers.py +122 -0
- omniload/src/airtable/__init__.py +84 -0
- omniload/src/allium/__init__.py +128 -0
- omniload/src/anthropic/__init__.py +277 -0
- omniload/src/anthropic/helpers.py +525 -0
- omniload/src/applovin/__init__.py +316 -0
- omniload/src/applovin_max/__init__.py +117 -0
- omniload/src/appsflyer/__init__.py +325 -0
- omniload/src/appsflyer/client.py +110 -0
- omniload/src/appstore/__init__.py +142 -0
- omniload/src/appstore/client.py +126 -0
- omniload/src/appstore/errors.py +15 -0
- omniload/src/appstore/models.py +117 -0
- omniload/src/appstore/resources.py +179 -0
- omniload/src/arrow/__init__.py +81 -0
- omniload/src/asana_source/__init__.py +281 -0
- omniload/src/asana_source/helpers.py +30 -0
- omniload/src/asana_source/settings.py +158 -0
- omniload/src/attio/__init__.py +102 -0
- omniload/src/attio/helpers.py +65 -0
- omniload/src/blob.py +95 -0
- omniload/src/bruin/__init__.py +76 -0
- omniload/src/chess/__init__.py +180 -0
- omniload/src/chess/helpers.py +35 -0
- omniload/src/chess/settings.py +18 -0
- omniload/src/clickup/__init__.py +85 -0
- omniload/src/clickup/helpers.py +47 -0
- omniload/src/collector/spinner.py +43 -0
- omniload/src/couchbase_source/__init__.py +118 -0
- omniload/src/couchbase_source/helpers.py +135 -0
- omniload/src/cursor/__init__.py +83 -0
- omniload/src/cursor/helpers.py +188 -0
- omniload/src/customer_io/__init__.py +486 -0
- omniload/src/customer_io/helpers.py +530 -0
- omniload/src/destinations.py +982 -0
- omniload/src/docebo/__init__.py +589 -0
- omniload/src/docebo/client.py +435 -0
- omniload/src/docebo/helpers.py +97 -0
- omniload/src/dune/__init__.py +104 -0
- omniload/src/dune/helpers.py +108 -0
- omniload/src/dynamodb/__init__.py +86 -0
- omniload/src/elasticsearch/__init__.py +80 -0
- omniload/src/elasticsearch/helpers.py +141 -0
- omniload/src/errors.py +26 -0
- omniload/src/facebook_ads/__init__.py +403 -0
- omniload/src/facebook_ads/exceptions.py +19 -0
- omniload/src/facebook_ads/helpers.py +296 -0
- omniload/src/facebook_ads/settings.py +224 -0
- omniload/src/facebook_ads/utils.py +53 -0
- omniload/src/factory.py +305 -0
- omniload/src/filesystem/__init__.py +133 -0
- omniload/src/filesystem/helpers.py +114 -0
- omniload/src/filesystem/readers.py +187 -0
- omniload/src/filters.py +62 -0
- omniload/src/fireflies/__init__.py +151 -0
- omniload/src/fireflies/helpers.py +753 -0
- omniload/src/fluxx/__init__.py +10013 -0
- omniload/src/fluxx/helpers.py +233 -0
- omniload/src/frankfurter/__init__.py +157 -0
- omniload/src/frankfurter/helpers.py +48 -0
- omniload/src/freshdesk/__init__.py +103 -0
- omniload/src/freshdesk/freshdesk_client.py +151 -0
- omniload/src/freshdesk/settings.py +23 -0
- omniload/src/fundraiseup/__init__.py +95 -0
- omniload/src/fundraiseup/client.py +81 -0
- omniload/src/github/__init__.py +202 -0
- omniload/src/github/helpers.py +207 -0
- omniload/src/github/queries.py +129 -0
- omniload/src/github/settings.py +24 -0
- omniload/src/google_ads/__init__.py +198 -0
- omniload/src/google_ads/field.py +17 -0
- omniload/src/google_ads/metrics.py +254 -0
- omniload/src/google_ads/predicates.py +37 -0
- omniload/src/google_ads/reports.py +411 -0
- omniload/src/google_ads/test_google_ads.py +184 -0
- omniload/src/google_analytics/__init__.py +144 -0
- omniload/src/google_analytics/helpers.py +312 -0
- omniload/src/google_sheets/README.md +95 -0
- omniload/src/google_sheets/__init__.py +166 -0
- omniload/src/google_sheets/helpers/__init__.py +15 -0
- omniload/src/google_sheets/helpers/api_calls.py +160 -0
- omniload/src/google_sheets/helpers/data_processing.py +316 -0
- omniload/src/gorgias/__init__.py +595 -0
- omniload/src/gorgias/helpers.py +166 -0
- omniload/src/hostaway/__init__.py +302 -0
- omniload/src/hostaway/client.py +288 -0
- omniload/src/http/__init__.py +38 -0
- omniload/src/http/readers.py +146 -0
- omniload/src/http_client.py +24 -0
- omniload/src/hubspot/__init__.py +800 -0
- omniload/src/hubspot/helpers.py +417 -0
- omniload/src/hubspot/settings.py +329 -0
- omniload/src/indeed/__init__.py +153 -0
- omniload/src/indeed/helpers.py +228 -0
- omniload/src/influxdb/__init__.py +46 -0
- omniload/src/influxdb/client.py +34 -0
- omniload/src/intercom/__init__.py +142 -0
- omniload/src/intercom/helpers.py +674 -0
- omniload/src/intercom/settings.py +279 -0
- omniload/src/isoc_pulse/__init__.py +159 -0
- omniload/src/jira_source/__init__.py +377 -0
- omniload/src/jira_source/helpers.py +510 -0
- omniload/src/jira_source/settings.py +184 -0
- omniload/src/kafka/__init__.py +120 -0
- omniload/src/kafka/helpers.py +241 -0
- omniload/src/kinesis/__init__.py +153 -0
- omniload/src/kinesis/helpers.py +96 -0
- omniload/src/klaviyo/__init__.py +237 -0
- omniload/src/klaviyo/client.py +212 -0
- omniload/src/klaviyo/helpers.py +19 -0
- omniload/src/linear/__init__.py +634 -0
- omniload/src/linear/helpers.py +111 -0
- omniload/src/linkedin_ads/__init__.py +266 -0
- omniload/src/linkedin_ads/dimension_time_enum.py +17 -0
- omniload/src/linkedin_ads/helpers.py +246 -0
- omniload/src/loader.py +69 -0
- omniload/src/mailchimp/__init__.py +126 -0
- omniload/src/mailchimp/helpers.py +226 -0
- omniload/src/mailchimp/settings.py +164 -0
- omniload/src/masking.py +344 -0
- omniload/src/mixpanel/__init__.py +62 -0
- omniload/src/mixpanel/client.py +104 -0
- omniload/src/monday/__init__.py +246 -0
- omniload/src/monday/helpers.py +392 -0
- omniload/src/monday/settings.py +325 -0
- omniload/src/mongodb/__init__.py +281 -0
- omniload/src/mongodb/helpers.py +975 -0
- omniload/src/notion/__init__.py +69 -0
- omniload/src/notion/helpers/__init__.py +14 -0
- omniload/src/notion/helpers/client.py +178 -0
- omniload/src/notion/helpers/database.py +92 -0
- omniload/src/notion/settings.py +17 -0
- omniload/src/partition.py +32 -0
- omniload/src/personio/__init__.py +345 -0
- omniload/src/personio/helpers.py +100 -0
- omniload/src/phantombuster/__init__.py +65 -0
- omniload/src/phantombuster/client.py +87 -0
- omniload/src/pinterest/__init__.py +82 -0
- omniload/src/pipedrive/__init__.py +212 -0
- omniload/src/pipedrive/helpers/__init__.py +37 -0
- omniload/src/pipedrive/helpers/custom_fields_munger.py +116 -0
- omniload/src/pipedrive/helpers/pages.py +129 -0
- omniload/src/pipedrive/settings.py +41 -0
- omniload/src/pipedrive/typing.py +17 -0
- omniload/src/plusvibeai/__init__.py +335 -0
- omniload/src/plusvibeai/helpers.py +544 -0
- omniload/src/plusvibeai/settings.py +252 -0
- omniload/src/primer/__init__.py +45 -0
- omniload/src/primer/helpers.py +79 -0
- omniload/src/quickbooks/__init__.py +117 -0
- omniload/src/reddit_ads/__init__.py +183 -0
- omniload/src/reddit_ads/helpers.py +232 -0
- omniload/src/resource.py +40 -0
- omniload/src/revenuecat/__init__.py +83 -0
- omniload/src/revenuecat/helpers.py +237 -0
- omniload/src/salesforce/__init__.py +170 -0
- omniload/src/salesforce/helpers.py +78 -0
- omniload/src/shopify/__init__.py +1953 -0
- omniload/src/shopify/exceptions.py +17 -0
- omniload/src/shopify/helpers.py +202 -0
- omniload/src/shopify/settings.py +19 -0
- omniload/src/slack/__init__.py +290 -0
- omniload/src/slack/helpers.py +218 -0
- omniload/src/slack/settings.py +36 -0
- omniload/src/smartsheets/__init__.py +82 -0
- omniload/src/snapchat_ads/__init__.py +455 -0
- omniload/src/snapchat_ads/client.py +72 -0
- omniload/src/snapchat_ads/helpers.py +630 -0
- omniload/src/snapchat_ads/settings.py +130 -0
- omniload/src/socrata_source/__init__.py +83 -0
- omniload/src/socrata_source/helpers.py +85 -0
- omniload/src/socrata_source/settings.py +8 -0
- omniload/src/solidgate/__init__.py +219 -0
- omniload/src/solidgate/helpers.py +154 -0
- omniload/src/sources.py +5408 -0
- omniload/src/sql_database/__init__.py +0 -0
- omniload/src/sql_database/callbacks.py +66 -0
- omniload/src/stripe_analytics/__init__.py +183 -0
- omniload/src/stripe_analytics/helpers.py +386 -0
- omniload/src/stripe_analytics/settings.py +80 -0
- omniload/src/table_definition.py +15 -0
- omniload/src/testdata/fakebqcredentials.json +14 -0
- omniload/src/tiktok_ads/__init__.py +150 -0
- omniload/src/tiktok_ads/tiktok_helpers.py +130 -0
- omniload/src/time.py +11 -0
- omniload/src/trustpilot/__init__.py +48 -0
- omniload/src/trustpilot/client.py +48 -0
- omniload/src/version.py +6 -0
- omniload/src/wise/__init__.py +68 -0
- omniload/src/wise/client.py +63 -0
- omniload/src/zendesk/__init__.py +480 -0
- omniload/src/zendesk/helpers/__init__.py +39 -0
- omniload/src/zendesk/helpers/api_helpers.py +119 -0
- omniload/src/zendesk/helpers/credentials.py +68 -0
- omniload/src/zendesk/helpers/talk_api.py +132 -0
- omniload/src/zendesk/settings.py +71 -0
- omniload/src/zoom/__init__.py +99 -0
- omniload/src/zoom/helpers.py +102 -0
- omniload/testdata/.gitignore +2 -0
- omniload/testdata/create_replace.csv +21 -0
- omniload/testdata/delete_insert_expected.csv +6 -0
- omniload/testdata/delete_insert_part1.csv +5 -0
- omniload/testdata/delete_insert_part2.csv +6 -0
- omniload/testdata/merge_expected.csv +5 -0
- omniload/testdata/merge_part1.csv +4 -0
- omniload/testdata/merge_part2.csv +5 -0
- omniload/tests/unit/test_smartsheets.py +133 -0
- omniload-0.0.0.dev0.dist-info/METADATA +439 -0
- omniload-0.0.0.dev0.dist-info/RECORD +218 -0
- omniload-0.0.0.dev0.dist-info/WHEEL +4 -0
- omniload-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- omniload-0.0.0.dev0.dist-info/licenses/LICENSE.Apache-2.0 +201 -0
- omniload-0.0.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- 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
|
omniload/src/resource.py
ADDED
|
@@ -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
|