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,325 @@
1
+ from typing import Iterable
2
+
3
+ import dlt
4
+ import pendulum
5
+ from dlt.common.typing import TDataItem
6
+ from dlt.sources import DltResource
7
+
8
+ from omniload.src.appsflyer.client import AppsflyerClient
9
+
10
+ DIMENSION_RESPONSE_MAPPING = {
11
+ "c": "campaign",
12
+ "af_adset_id": "adset_id",
13
+ "af_adset": "adset",
14
+ "af_ad_id": "ad_id",
15
+ }
16
+ HINTS = {
17
+ "app_id": {
18
+ "data_type": "text",
19
+ "nullable": False,
20
+ },
21
+ "campaign": {
22
+ "data_type": "text",
23
+ "nullable": False,
24
+ },
25
+ "geo": {
26
+ "data_type": "text",
27
+ "nullable": False,
28
+ },
29
+ "cost": {
30
+ "data_type": "decimal",
31
+ "precision": 30,
32
+ "scale": 5,
33
+ "nullable": False,
34
+ },
35
+ "clicks": {
36
+ "data_type": "bigint",
37
+ "nullable": False,
38
+ },
39
+ "impressions": {
40
+ "data_type": "bigint",
41
+ "nullable": False,
42
+ },
43
+ "average_ecpi": {
44
+ "data_type": "decimal",
45
+ "precision": 30,
46
+ "scale": 5,
47
+ "nullable": False,
48
+ },
49
+ "installs": {
50
+ "data_type": "bigint",
51
+ "nullable": False,
52
+ },
53
+ "retention_day_7": {
54
+ "data_type": "decimal",
55
+ "precision": 30,
56
+ "scale": 5,
57
+ "nullable": False,
58
+ },
59
+ "retention_day_14": {
60
+ "data_type": "decimal",
61
+ "precision": 30,
62
+ "scale": 5,
63
+ "nullable": False,
64
+ },
65
+ "cohort_day_1_revenue_per_user": {
66
+ "data_type": "decimal",
67
+ "precision": 30,
68
+ "scale": 5,
69
+ "nullable": True,
70
+ },
71
+ "cohort_day_1_total_revenue_per_user": {
72
+ "data_type": "decimal",
73
+ "precision": 30,
74
+ "scale": 5,
75
+ "nullable": True,
76
+ },
77
+ "cohort_day_3_revenue_per_user": {
78
+ "data_type": "decimal",
79
+ "precision": 30,
80
+ "scale": 5,
81
+ "nullable": True,
82
+ },
83
+ "cohort_day_3_total_revenue_per_user": {
84
+ "data_type": "decimal",
85
+ "precision": 30,
86
+ "scale": 5,
87
+ "nullable": True,
88
+ },
89
+ "cohort_day_7_revenue_per_user": {
90
+ "data_type": "decimal",
91
+ "precision": 30,
92
+ "scale": 5,
93
+ "nullable": True,
94
+ },
95
+ "cohort_day_7_total_revenue_per_user": {
96
+ "data_type": "decimal",
97
+ "precision": 30,
98
+ "scale": 5,
99
+ "nullable": True,
100
+ },
101
+ "cohort_day_14_revenue_per_user": {
102
+ "data_type": "decimal",
103
+ "precision": 30,
104
+ "scale": 5,
105
+ "nullable": True,
106
+ },
107
+ "cohort_day_14_total_revenue_per_user": {
108
+ "data_type": "decimal",
109
+ "precision": 30,
110
+ "scale": 5,
111
+ "nullable": True,
112
+ },
113
+ "cohort_day_21_revenue_per_user": {
114
+ "data_type": "decimal",
115
+ "precision": 30,
116
+ "scale": 5,
117
+ "nullable": True,
118
+ },
119
+ "cohort_day_21_total_revenue_per_user": {
120
+ "data_type": "decimal",
121
+ "precision": 30,
122
+ "scale": 5,
123
+ "nullable": True,
124
+ },
125
+ "install_time": {
126
+ "data_type": "date",
127
+ "nullable": False,
128
+ },
129
+ "loyal_users": {
130
+ "data_type": "bigint",
131
+ "nullable": False,
132
+ },
133
+ "revenue": {
134
+ "data_type": "decimal",
135
+ "precision": 30,
136
+ "scale": 5,
137
+ "nullable": True,
138
+ },
139
+ "roi": {
140
+ "data_type": "decimal",
141
+ "precision": 30,
142
+ "scale": 5,
143
+ "nullable": True,
144
+ },
145
+ "uninstalls": {
146
+ "data_type": "bigint",
147
+ "nullable": True,
148
+ },
149
+ }
150
+
151
+ CAMPAIGNS_DIMENSIONS = ["c", "geo", "app_id", "install_time"]
152
+ CAMPAIGNS_METRICS = [
153
+ "average_ecpi",
154
+ "clicks",
155
+ "cohort_day_1_revenue_per_user",
156
+ "cohort_day_1_total_revenue_per_user",
157
+ "cohort_day_14_revenue_per_user",
158
+ "cohort_day_14_total_revenue_per_user",
159
+ "cohort_day_21_revenue_per_user",
160
+ "cohort_day_21_total_revenue_per_user",
161
+ "cohort_day_3_revenue_per_user",
162
+ "cohort_day_3_total_revenue_per_user",
163
+ "cohort_day_7_revenue_per_user",
164
+ "cohort_day_7_total_revenue_per_user",
165
+ "cost",
166
+ "impressions",
167
+ "installs",
168
+ "loyal_users",
169
+ "retention_day_7",
170
+ "revenue",
171
+ "roi",
172
+ "uninstalls",
173
+ ]
174
+
175
+ CREATIVES_DIMENSIONS = [
176
+ "c",
177
+ "geo",
178
+ "app_id",
179
+ "install_time",
180
+ "af_adset_id",
181
+ "af_adset",
182
+ "af_ad_id",
183
+ ]
184
+ CREATIVES_METRICS = [
185
+ "impressions",
186
+ "clicks",
187
+ "installs",
188
+ "cost",
189
+ "revenue",
190
+ "average_ecpi",
191
+ "loyal_users",
192
+ "uninstalls",
193
+ "roi",
194
+ ]
195
+
196
+
197
+ @dlt.source(max_table_nesting=0)
198
+ def appsflyer_source(
199
+ api_key: str,
200
+ start_date: str,
201
+ end_date: str,
202
+ dimensions: list[str],
203
+ metrics: list[str],
204
+ ) -> Iterable[DltResource]:
205
+ client = AppsflyerClient(api_key)
206
+
207
+ @dlt.resource(
208
+ write_disposition="merge",
209
+ merge_key="install_time",
210
+ columns=make_hints(CAMPAIGNS_DIMENSIONS, CAMPAIGNS_METRICS),
211
+ )
212
+ def campaigns(
213
+ datetime=dlt.sources.incremental(
214
+ "install_time",
215
+ initial_value=(
216
+ start_date
217
+ if start_date
218
+ else pendulum.today().subtract(days=30).format("YYYY-MM-DD")
219
+ ),
220
+ end_value=end_date,
221
+ range_end="closed",
222
+ range_start="closed",
223
+ ),
224
+ ) -> Iterable[TDataItem]:
225
+ end = (
226
+ datetime.end_value
227
+ if datetime.end_value
228
+ else pendulum.now().format("YYYY-MM-DD")
229
+ )
230
+
231
+ yield from client._fetch_data(
232
+ from_date=datetime.last_value,
233
+ to_date=end,
234
+ dimensions=CAMPAIGNS_DIMENSIONS,
235
+ metrics=CAMPAIGNS_METRICS,
236
+ )
237
+
238
+ @dlt.resource(
239
+ write_disposition="merge",
240
+ merge_key="install_time",
241
+ columns=make_hints(CREATIVES_DIMENSIONS, CREATIVES_METRICS),
242
+ )
243
+ def creatives(
244
+ datetime=dlt.sources.incremental(
245
+ "install_time",
246
+ initial_value=(
247
+ start_date
248
+ if start_date
249
+ else pendulum.today().subtract(days=30).format("YYYY-MM-DD")
250
+ ),
251
+ end_value=end_date,
252
+ range_end="closed",
253
+ range_start="closed",
254
+ ),
255
+ ) -> Iterable[TDataItem]:
256
+ end = (
257
+ datetime.end_value
258
+ if datetime.end_value
259
+ else pendulum.now().format("YYYY-MM-DD")
260
+ )
261
+ yield from client._fetch_data(
262
+ datetime.last_value,
263
+ end,
264
+ dimensions=CREATIVES_DIMENSIONS,
265
+ metrics=CREATIVES_METRICS,
266
+ )
267
+
268
+ primary_keys = []
269
+ if "install_time" not in dimensions:
270
+ dimensions.append("install_time")
271
+ primary_keys.append("install_time")
272
+
273
+ for dimension in dimensions:
274
+ if dimension in DIMENSION_RESPONSE_MAPPING:
275
+ primary_keys.append(DIMENSION_RESPONSE_MAPPING[dimension])
276
+ else:
277
+ primary_keys.append(dimension)
278
+
279
+ @dlt.resource(
280
+ write_disposition="merge",
281
+ primary_key=primary_keys,
282
+ columns=make_hints(dimensions, metrics),
283
+ )
284
+ def custom(
285
+ datetime=dlt.sources.incremental(
286
+ "install_time",
287
+ initial_value=(
288
+ start_date
289
+ if start_date
290
+ else pendulum.today().subtract(days=30).format("YYYY-MM-DD")
291
+ ),
292
+ end_value=end_date,
293
+ ),
294
+ ):
295
+ end = (
296
+ datetime.end_value
297
+ if datetime.end_value
298
+ else pendulum.now().format("YYYY-MM-DD")
299
+ )
300
+ res = client._fetch_data(
301
+ from_date=datetime.last_value,
302
+ to_date=end,
303
+ dimensions=dimensions,
304
+ metrics=metrics,
305
+ )
306
+ yield from res
307
+
308
+ return campaigns, creatives, custom
309
+
310
+
311
+ def make_hints(dimensions: list[str], metrics: list[str]):
312
+ campaign_hints = {}
313
+ for dimension in dimensions:
314
+ resp_key = dimension
315
+ if dimension in DIMENSION_RESPONSE_MAPPING:
316
+ resp_key = DIMENSION_RESPONSE_MAPPING[dimension]
317
+
318
+ if resp_key in HINTS:
319
+ campaign_hints[resp_key] = HINTS[resp_key]
320
+
321
+ for metric in metrics:
322
+ if metric in HINTS:
323
+ campaign_hints[metric] = HINTS[metric]
324
+
325
+ return campaign_hints
@@ -0,0 +1,110 @@
1
+ from typing import Optional
2
+
3
+ import requests
4
+ from dlt.sources.helpers.requests import Client
5
+ from requests.exceptions import HTTPError
6
+
7
+
8
+ class AppsflyerClient:
9
+ def __init__(self, api_key: str):
10
+ self.api_key = api_key
11
+ self.uri = "https://hq1.appsflyer.com/api/master-agg-data/v4/app/all"
12
+
13
+ def __get_headers(self):
14
+ return {
15
+ "Authorization": f"{self.api_key}",
16
+ "accept": "text/json",
17
+ }
18
+
19
+ def _fetch_data(
20
+ self,
21
+ from_date: str,
22
+ to_date: str,
23
+ dimensions: list[str],
24
+ metrics: list[str],
25
+ maximum_rows=1000000,
26
+ ):
27
+ excluded_metrics = exclude_metrics_for_date_range(metrics, from_date, to_date)
28
+ included_metrics = [
29
+ metric for metric in metrics if metric not in excluded_metrics
30
+ ]
31
+
32
+ params = {
33
+ "from": from_date,
34
+ "to": to_date,
35
+ "groupings": ",".join(dimensions),
36
+ "kpis": ",".join(included_metrics),
37
+ "format": "json",
38
+ "maximum_rows": maximum_rows,
39
+ }
40
+
41
+ def retry_on_limit(
42
+ response: Optional[requests.Response], exception: Optional[BaseException]
43
+ ) -> bool:
44
+ return (
45
+ isinstance(response, requests.Response) and response.status_code == 429
46
+ )
47
+
48
+ request_client = Client(
49
+ raise_for_status=False,
50
+ retry_condition=retry_on_limit,
51
+ request_max_attempts=12,
52
+ request_backoff_factor=2,
53
+ ).session
54
+
55
+ try:
56
+ response = request_client.get(
57
+ url=self.uri, headers=self.__get_headers(), params=params
58
+ )
59
+
60
+ if response.status_code == 200:
61
+ result = response.json()
62
+ yield standardize_keys(result, excluded_metrics)
63
+ else:
64
+ raise HTTPError(
65
+ f"Request failed with status code: {response.status_code}: {response.text}"
66
+ )
67
+
68
+ except requests.RequestException as e:
69
+ raise HTTPError(f"Request failed: {e}")
70
+
71
+
72
+ def standardize_keys(data: list[dict], excluded_metrics: list[str]) -> list[dict]:
73
+ def fix_key(key: str) -> str:
74
+ return key.lower().replace("-", "").replace(" ", "_").replace(" ", "_")
75
+
76
+ standardized = []
77
+ for item in data:
78
+ standardized_item = {}
79
+ for key, value in item.items():
80
+ standardized_item[fix_key(key)] = value
81
+
82
+ for metric in excluded_metrics:
83
+ if metric not in standardized_item:
84
+ standardized_item[fix_key(metric)] = None
85
+
86
+ standardized.append(standardized_item)
87
+
88
+ return standardized
89
+
90
+
91
+ def exclude_metrics_for_date_range(
92
+ metrics: list[str], from_date: str, to_date: str
93
+ ) -> list[str]:
94
+ """
95
+ Some of the cohort metrics are not available if there hasn't been enough time to have data for that cohort.
96
+ This means if you request data for yesterday with cohort day 7 metrics, you will get an error because 7 days hasn't passed yet.
97
+ One would expect the API to handle this gracefully, but it doesn't.
98
+
99
+ This function will exclude the metrics that are not available for the given date range.
100
+ """
101
+ import pendulum
102
+
103
+ excluded_metrics = []
104
+ days_between_today_and_end = (pendulum.now() - pendulum.parse(to_date)).days # type: ignore
105
+ for metric in metrics:
106
+ if "cohort_day_" in metric:
107
+ day_count = int(metric.split("_")[2])
108
+ if days_between_today_and_end <= day_count:
109
+ excluded_metrics.append(metric)
110
+ return excluded_metrics
@@ -0,0 +1,142 @@
1
+ import csv
2
+ import gzip
3
+ import os
4
+ import tempfile
5
+ from copy import deepcopy
6
+ from datetime import datetime
7
+ from typing import Iterable, List, Optional
8
+
9
+ import dlt
10
+ import requests
11
+ from dlt.common.typing import TDataItem
12
+ from dlt.sources import DltResource
13
+
14
+ from .client import AppStoreConnectClientInterface
15
+ from .errors import (
16
+ NoOngoingReportRequestsFoundError,
17
+ NoReportsFoundError,
18
+ NoSuchReportError,
19
+ )
20
+ from .models import AnalyticsReportInstancesResponse
21
+ from .resources import RESOURCES
22
+
23
+
24
+ @dlt.source
25
+ def app_store(
26
+ client: AppStoreConnectClientInterface,
27
+ app_ids: List[str],
28
+ start_date: Optional[datetime] = None,
29
+ end_date: Optional[datetime] = None,
30
+ ) -> Iterable[DltResource]:
31
+ if start_date and start_date.tzinfo is not None:
32
+ start_date = start_date.replace(tzinfo=None)
33
+ if end_date and end_date.tzinfo is not None:
34
+ end_date = end_date.replace(tzinfo=None)
35
+ for resource in RESOURCES:
36
+ yield dlt.resource(
37
+ get_analytics_reports,
38
+ name=resource.name,
39
+ primary_key=resource.primary_key,
40
+ columns=resource.columns,
41
+ write_disposition="merge",
42
+ )(client, app_ids, resource.report_name, start_date, end_date)
43
+
44
+
45
+ def filter_instances_by_date(
46
+ instances: AnalyticsReportInstancesResponse,
47
+ start_date: Optional[datetime],
48
+ end_date: Optional[datetime],
49
+ ) -> AnalyticsReportInstancesResponse:
50
+ instances = deepcopy(instances)
51
+ if start_date is not None:
52
+ instances.data = list(
53
+ filter(
54
+ lambda x: datetime.fromisoformat(x.attributes.processingDate)
55
+ >= start_date,
56
+ instances.data,
57
+ )
58
+ )
59
+ if end_date is not None:
60
+ instances.data = list(
61
+ filter(
62
+ lambda x: datetime.fromisoformat(x.attributes.processingDate)
63
+ <= end_date,
64
+ instances.data,
65
+ )
66
+ )
67
+
68
+ return instances
69
+
70
+
71
+ def get_analytics_reports(
72
+ client: AppStoreConnectClientInterface,
73
+ app_ids: List[str],
74
+ report_name: str,
75
+ start_date: Optional[datetime],
76
+ end_date: Optional[datetime],
77
+ last_processing_date=dlt.sources.incremental("processing_date"),
78
+ ) -> Iterable[TDataItem]:
79
+ if last_processing_date.last_value:
80
+ start_date = datetime.fromisoformat(last_processing_date.last_value)
81
+ for app_id in app_ids:
82
+ yield from get_report(client, app_id, report_name, start_date, end_date)
83
+
84
+
85
+ def get_report(
86
+ client: AppStoreConnectClientInterface,
87
+ app_id: str,
88
+ report_name: str,
89
+ start_date: Optional[datetime],
90
+ end_date: Optional[datetime],
91
+ ) -> Iterable[TDataItem]:
92
+ report_requests = client.list_analytics_report_requests(app_id)
93
+ ongoing_requests = list(
94
+ filter(
95
+ lambda x: x.attributes.accessType == "ONGOING"
96
+ and not x.attributes.stoppedDueToInactivity,
97
+ report_requests.data,
98
+ )
99
+ )
100
+
101
+ if len(ongoing_requests) == 0:
102
+ raise NoOngoingReportRequestsFoundError()
103
+
104
+ reports = client.list_analytics_reports(ongoing_requests[0].id, report_name)
105
+ if len(reports.data) == 0:
106
+ raise NoSuchReportError(report_name)
107
+
108
+ for report in reports.data:
109
+ instances = client.list_report_instances(report.id)
110
+
111
+ instances = filter_instances_by_date(instances, start_date, end_date)
112
+
113
+ if len(instances.data) == 0:
114
+ raise NoReportsFoundError()
115
+
116
+ for instance in instances.data:
117
+ segments = client.list_report_segments(instance.id)
118
+ with tempfile.TemporaryDirectory() as temp_dir:
119
+ files = []
120
+ for segment in segments.data:
121
+ payload = requests.get(segment.attributes.url, stream=True)
122
+ payload.raise_for_status()
123
+
124
+ csv_path = os.path.join(
125
+ temp_dir, f"{segment.attributes.checksum}.csv"
126
+ )
127
+ with open(csv_path, "wb") as f:
128
+ for chunk in payload.iter_content(chunk_size=8192):
129
+ f.write(chunk)
130
+ files.append(csv_path)
131
+ for file in files:
132
+ with gzip.open(file, "rt") as f:
133
+ # TODO: infer delimiter from the file itself
134
+ delimiter = (
135
+ "," if report_name == "App Crashes Expanded" else "\t"
136
+ )
137
+ reader = csv.DictReader(f, delimiter=delimiter)
138
+ for row in reader:
139
+ yield {
140
+ "processing_date": instance.attributes.processingDate,
141
+ **row,
142
+ }
@@ -0,0 +1,126 @@
1
+ import abc
2
+ import time
3
+ from typing import Optional
4
+
5
+ import jwt
6
+ import requests
7
+ from requests.models import PreparedRequest
8
+
9
+ from .models import (
10
+ AnalyticsReportInstancesResponse,
11
+ AnalyticsReportRequestsResponse,
12
+ AnalyticsReportResponse,
13
+ AnalyticsReportSegmentsResponse,
14
+ )
15
+
16
+
17
+ class AppStoreConnectClientInterface(abc.ABC):
18
+ @abc.abstractmethod
19
+ def list_analytics_report_requests(self, app_id) -> AnalyticsReportRequestsResponse:
20
+ pass
21
+
22
+ @abc.abstractmethod
23
+ def list_analytics_reports(
24
+ self, req_id: str, report_name: str
25
+ ) -> AnalyticsReportResponse:
26
+ pass
27
+
28
+ @abc.abstractmethod
29
+ def list_report_instances(
30
+ self,
31
+ report_id: str,
32
+ granularity: str = "DAILY",
33
+ ) -> AnalyticsReportInstancesResponse:
34
+ pass
35
+
36
+ @abc.abstractmethod
37
+ def list_report_segments(self, instance_id: str) -> AnalyticsReportSegmentsResponse:
38
+ pass
39
+
40
+
41
+ class AppStoreConnectClient(AppStoreConnectClientInterface):
42
+ def __init__(self, key: bytes, key_id: str, issuer_id: str):
43
+ self.__key = key
44
+ self.__key_id = key_id
45
+ self.__issuer_id = issuer_id
46
+
47
+ def list_analytics_report_requests(self, app_id) -> AnalyticsReportRequestsResponse:
48
+ res = requests.get(
49
+ f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/analyticsReportRequests",
50
+ auth=self.auth,
51
+ )
52
+ res.raise_for_status()
53
+
54
+ return AnalyticsReportRequestsResponse.from_json(res.text) # type: ignore
55
+
56
+ def list_analytics_reports(
57
+ self, req_id: str, report_name: str
58
+ ) -> AnalyticsReportResponse:
59
+ params = {"filter[name]": report_name}
60
+ res = requests.get(
61
+ f"https://api.appstoreconnect.apple.com/v1/analyticsReportRequests/{req_id}/reports",
62
+ auth=self.auth,
63
+ params=params,
64
+ )
65
+ res.raise_for_status()
66
+ return AnalyticsReportResponse.from_json(res.text) # type: ignore
67
+
68
+ def list_report_instances(
69
+ self,
70
+ report_id: str,
71
+ granularity: str = "DAILY",
72
+ ) -> AnalyticsReportInstancesResponse:
73
+ data = []
74
+ url = f"https://api.appstoreconnect.apple.com/v1/analyticsReports/{report_id}/instances"
75
+ params: Optional[dict] = {"filter[granularity]": granularity}
76
+
77
+ while url:
78
+ res = requests.get(url, auth=self.auth, params=params)
79
+ res.raise_for_status()
80
+
81
+ response_data = AnalyticsReportInstancesResponse.from_json(res.text) # type: ignore
82
+ data.extend(response_data.data)
83
+
84
+ url = response_data.links.next
85
+ params = None # Clear params for subsequent requests
86
+
87
+ return AnalyticsReportInstancesResponse(
88
+ data=data,
89
+ links=response_data.links,
90
+ meta=response_data.meta,
91
+ )
92
+
93
+ def list_report_segments(self, instance_id: str) -> AnalyticsReportSegmentsResponse:
94
+ segments = []
95
+ url = f"https://api.appstoreconnect.apple.com/v1/analyticsReportInstances/{instance_id}/segments"
96
+
97
+ while url:
98
+ res = requests.get(url, auth=self.auth)
99
+ res.raise_for_status()
100
+
101
+ response_data = AnalyticsReportSegmentsResponse.from_json(res.text) # type: ignore
102
+ segments.extend(response_data.data)
103
+
104
+ url = response_data.links.next
105
+
106
+ return AnalyticsReportSegmentsResponse(
107
+ data=segments, links=response_data.links, meta=response_data.meta
108
+ )
109
+
110
+ def auth(self, req: PreparedRequest) -> PreparedRequest:
111
+ headers = {
112
+ "alg": "ES256",
113
+ "kid": self.__key_id,
114
+ }
115
+ payload = {
116
+ "iss": self.__issuer_id,
117
+ "exp": int(time.time()) + 600,
118
+ "aud": "appstoreconnect-v1",
119
+ }
120
+ req.headers["Authorization"] = jwt.encode(
121
+ payload,
122
+ self.__key,
123
+ algorithm="ES256",
124
+ headers=headers,
125
+ )
126
+ return req