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,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
|