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,630 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Iterator
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from .client import SnapchatAdsAPI, create_client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ParsedStatsTable:
|
|
11
|
+
"""Parsed stats table configuration.
|
|
12
|
+
|
|
13
|
+
Table format: <resource-name>:<dimension-like-values>:<metrics>
|
|
14
|
+
|
|
15
|
+
Dimension-like values (order-independent, comma-separated):
|
|
16
|
+
- granularity (required): TOTAL, DAY, HOUR, LIFETIME
|
|
17
|
+
- breakdown (optional): ad, adsquad, campaign
|
|
18
|
+
- dimension (optional): GEO, DEMO, INTEREST, DEVICE
|
|
19
|
+
- pivot (optional): country, region, dma, gender, age_bucket, etc.
|
|
20
|
+
|
|
21
|
+
Metrics: comma-separated field names (default: impressions,spend)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
resource_name: str
|
|
25
|
+
granularity: str
|
|
26
|
+
fields: str
|
|
27
|
+
breakdown: str | None = None
|
|
28
|
+
dimension: str | None = None
|
|
29
|
+
pivot: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Module-level constant for entity type mapping
|
|
33
|
+
ENTITY_TYPE_MAP = {
|
|
34
|
+
"campaign": "campaigns",
|
|
35
|
+
"adsquad": "adsquads",
|
|
36
|
+
"ad": "ads",
|
|
37
|
+
"adaccount": "adaccounts",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_metadata_fields(source: dict, **overrides) -> dict:
|
|
42
|
+
metadata_keys = [
|
|
43
|
+
"start_time",
|
|
44
|
+
"end_time",
|
|
45
|
+
"finalized_data_end_time",
|
|
46
|
+
]
|
|
47
|
+
result = {key: source.get(key) for key in metadata_keys}
|
|
48
|
+
result.update(overrides)
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_semantic_entity_fields(
|
|
53
|
+
record: dict,
|
|
54
|
+
entity_type: str,
|
|
55
|
+
entity_id: str,
|
|
56
|
+
breakdown_type: str | None = None,
|
|
57
|
+
breakdown_id: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Add semantic entity ID fields to a record in-place."""
|
|
60
|
+
parent_field_name = f"{entity_type.lower()}_id"
|
|
61
|
+
record[parent_field_name] = entity_id
|
|
62
|
+
|
|
63
|
+
if breakdown_type and breakdown_id is not None:
|
|
64
|
+
breakdown_field_name = f"{breakdown_type}_id"
|
|
65
|
+
record[breakdown_field_name] = breakdown_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_stats_record(record: dict) -> dict:
|
|
69
|
+
"""Normalize stats record by ensuring required primary key fields exist.
|
|
70
|
+
|
|
71
|
+
Only campaign_id is required and will be filled with 'no_campaign_id' if missing.
|
|
72
|
+
Other fields (adsquad_id, ad_id) will be set to None if not present (no breakdown).
|
|
73
|
+
Time fields (start_time, end_time) are always expected to exist.
|
|
74
|
+
"""
|
|
75
|
+
# Ensure campaign_id exists (required field)
|
|
76
|
+
if "campaign_id" not in record or record["campaign_id"] is None:
|
|
77
|
+
record["campaign_id"] = "no_campaign_id"
|
|
78
|
+
|
|
79
|
+
# For optional breakdown fields, set to None if not present
|
|
80
|
+
for field in ["adsquad_id", "ad_id"]:
|
|
81
|
+
if field not in record:
|
|
82
|
+
record[field] = None
|
|
83
|
+
|
|
84
|
+
# Time fields should always exist, but add fallback
|
|
85
|
+
for field in ["start_time", "end_time"]:
|
|
86
|
+
if field not in record or record[field] is None:
|
|
87
|
+
record[field] = f"no_{field}"
|
|
88
|
+
|
|
89
|
+
return record
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def paginate(client: requests.Session, headers: dict, url: str, page_size: int = 1000):
|
|
93
|
+
"""
|
|
94
|
+
Helper to paginate through Snapchat API responses.
|
|
95
|
+
"""
|
|
96
|
+
from urllib.parse import parse_qs, urlparse
|
|
97
|
+
|
|
98
|
+
params: dict[str, int | str] = {"limit": page_size}
|
|
99
|
+
|
|
100
|
+
while url:
|
|
101
|
+
response = client.get(url, headers=headers, params=params)
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
|
|
104
|
+
result = response.json()
|
|
105
|
+
|
|
106
|
+
if result.get("request_status", "").upper() != "SUCCESS":
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Request failed: {result.get('request_status')} - {result}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
yield result
|
|
112
|
+
|
|
113
|
+
# Check for next page
|
|
114
|
+
paging = result.get("paging", {})
|
|
115
|
+
next_link = paging.get("next_link")
|
|
116
|
+
|
|
117
|
+
if next_link:
|
|
118
|
+
# Extract cursor from next_link
|
|
119
|
+
parsed = urlparse(next_link)
|
|
120
|
+
query_params = parse_qs(parsed.query)
|
|
121
|
+
cursor_list = query_params.get("cursor", [None])
|
|
122
|
+
cursor = cursor_list[0] if cursor_list else None
|
|
123
|
+
|
|
124
|
+
if cursor:
|
|
125
|
+
params["cursor"] = cursor
|
|
126
|
+
else:
|
|
127
|
+
break
|
|
128
|
+
else:
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_account_ids(
|
|
133
|
+
api: "SnapchatAdsAPI",
|
|
134
|
+
ad_account_id: list[str] | None,
|
|
135
|
+
organization_id: str | None,
|
|
136
|
+
base_url: str,
|
|
137
|
+
resource_name: str,
|
|
138
|
+
start_date=None,
|
|
139
|
+
end_date=None,
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
"""
|
|
142
|
+
Get list of account IDs to fetch data for.
|
|
143
|
+
|
|
144
|
+
If ad_account_id is provided, returns that list of accounts.
|
|
145
|
+
Otherwise, fetches all ad accounts for the organization.
|
|
146
|
+
"""
|
|
147
|
+
if ad_account_id:
|
|
148
|
+
return ad_account_id
|
|
149
|
+
|
|
150
|
+
if not organization_id:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"organization_id is required to fetch {resource_name} for all ad accounts"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
accounts_url = f"{base_url}/organizations/{organization_id}/adaccounts"
|
|
156
|
+
# Don't filter accounts by date - we want all accounts, then filter stats by date
|
|
157
|
+
accounts_data = list(
|
|
158
|
+
fetch_snapchat_data(api, accounts_url, "adaccounts", "adaccount", None, None)
|
|
159
|
+
)
|
|
160
|
+
return [
|
|
161
|
+
account_id
|
|
162
|
+
for account in accounts_data
|
|
163
|
+
if (account_id := account.get("id")) is not None
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fetch_snapchat_data(
|
|
168
|
+
api: "SnapchatAdsAPI",
|
|
169
|
+
url: str,
|
|
170
|
+
resource_key: str,
|
|
171
|
+
item_key: str,
|
|
172
|
+
start_date=None,
|
|
173
|
+
end_date=None,
|
|
174
|
+
) -> Iterator[dict]:
|
|
175
|
+
"""
|
|
176
|
+
Generic helper to fetch data from Snapchat API.
|
|
177
|
+
"""
|
|
178
|
+
client = create_client()
|
|
179
|
+
headers = api.get_headers()
|
|
180
|
+
|
|
181
|
+
response = client.get(url, headers=headers)
|
|
182
|
+
response.raise_for_status()
|
|
183
|
+
|
|
184
|
+
result = response.json()
|
|
185
|
+
|
|
186
|
+
if result.get("request_status", "").upper() != "SUCCESS":
|
|
187
|
+
raise ValueError(f"Request failed: {result.get('request_status')} - {result}")
|
|
188
|
+
|
|
189
|
+
items_data = result.get(resource_key, [])
|
|
190
|
+
|
|
191
|
+
for item in items_data:
|
|
192
|
+
if item.get("sub_request_status", "").upper() == "SUCCESS":
|
|
193
|
+
data = item.get(item_key, {})
|
|
194
|
+
if data:
|
|
195
|
+
yield data
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def fetch_snapchat_data_with_params(
|
|
199
|
+
api: "SnapchatAdsAPI",
|
|
200
|
+
url: str,
|
|
201
|
+
resource_key: str,
|
|
202
|
+
item_key: str,
|
|
203
|
+
params: dict | None = None,
|
|
204
|
+
) -> Iterator[dict]:
|
|
205
|
+
"""
|
|
206
|
+
Generic helper to fetch data from Snapchat API with query parameters.
|
|
207
|
+
"""
|
|
208
|
+
client = create_client()
|
|
209
|
+
headers = api.get_headers()
|
|
210
|
+
|
|
211
|
+
response = client.get(url, headers=headers, params=params or {})
|
|
212
|
+
response.raise_for_status()
|
|
213
|
+
|
|
214
|
+
result = response.json()
|
|
215
|
+
|
|
216
|
+
if result.get("request_status", "").upper() != "SUCCESS":
|
|
217
|
+
raise ValueError(f"Request failed: {result.get('request_status')} - {result}")
|
|
218
|
+
|
|
219
|
+
items_data = result.get(resource_key, [])
|
|
220
|
+
|
|
221
|
+
for item in items_data:
|
|
222
|
+
if item.get("sub_request_status", "").upper() == "SUCCESS":
|
|
223
|
+
data = item.get(item_key, {})
|
|
224
|
+
if data:
|
|
225
|
+
yield data
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def fetch_account_id_resource(
|
|
229
|
+
api: "SnapchatAdsAPI",
|
|
230
|
+
ad_account_id: list[str] | None,
|
|
231
|
+
organization_id: str | None,
|
|
232
|
+
base_url: str,
|
|
233
|
+
resource_name: str,
|
|
234
|
+
item_key: str,
|
|
235
|
+
start_date=None,
|
|
236
|
+
end_date=None,
|
|
237
|
+
) -> Iterator[dict]:
|
|
238
|
+
"""
|
|
239
|
+
Fetch resource data for ad accounts without pagination.
|
|
240
|
+
|
|
241
|
+
If ad_account_id is provided, fetches data for those specific accounts.
|
|
242
|
+
Otherwise, fetches all ad accounts and then fetches data for each account.
|
|
243
|
+
"""
|
|
244
|
+
account_ids = get_account_ids(
|
|
245
|
+
api,
|
|
246
|
+
ad_account_id,
|
|
247
|
+
organization_id,
|
|
248
|
+
base_url,
|
|
249
|
+
resource_name,
|
|
250
|
+
start_date,
|
|
251
|
+
end_date,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for account_id in account_ids:
|
|
255
|
+
url = f"{base_url}/adaccounts/{account_id}/{resource_name}"
|
|
256
|
+
yield from fetch_snapchat_data(
|
|
257
|
+
api, url, resource_name, item_key, start_date, end_date
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def fetch_with_paginate_account_id(
|
|
262
|
+
api: "SnapchatAdsAPI",
|
|
263
|
+
ad_account_id: list[str] | None,
|
|
264
|
+
organization_id: str | None,
|
|
265
|
+
base_url: str,
|
|
266
|
+
resource_name: str,
|
|
267
|
+
item_key: str,
|
|
268
|
+
start_date=None,
|
|
269
|
+
end_date=None,
|
|
270
|
+
) -> Iterator[dict]:
|
|
271
|
+
"""
|
|
272
|
+
Fetch paginated resource data for ad accounts.
|
|
273
|
+
|
|
274
|
+
If ad_account_id is provided, fetches data for those specific accounts.
|
|
275
|
+
Otherwise, fetches all ad accounts and then fetches data for each account.
|
|
276
|
+
"""
|
|
277
|
+
account_ids = get_account_ids(
|
|
278
|
+
api,
|
|
279
|
+
ad_account_id,
|
|
280
|
+
organization_id,
|
|
281
|
+
base_url,
|
|
282
|
+
resource_name,
|
|
283
|
+
start_date,
|
|
284
|
+
end_date,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
client = create_client()
|
|
288
|
+
headers = api.get_headers()
|
|
289
|
+
|
|
290
|
+
for account_id in account_ids:
|
|
291
|
+
url = f"{base_url}/adaccounts/{account_id}/{resource_name}"
|
|
292
|
+
|
|
293
|
+
for result in paginate(client, headers, url, page_size=1000):
|
|
294
|
+
items_data = result.get(resource_name, [])
|
|
295
|
+
|
|
296
|
+
for item in items_data:
|
|
297
|
+
if item.get("sub_request_status", "").upper() == "SUCCESS":
|
|
298
|
+
data = item.get(item_key, {})
|
|
299
|
+
if data:
|
|
300
|
+
yield data
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_stats_url(
|
|
304
|
+
base_url: str,
|
|
305
|
+
entity_type: str,
|
|
306
|
+
entity_id: str,
|
|
307
|
+
) -> str:
|
|
308
|
+
plural_entity = ENTITY_TYPE_MAP.get(entity_type)
|
|
309
|
+
if not plural_entity:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Invalid entity_type: {entity_type}. Must be one of: {list(ENTITY_TYPE_MAP.keys())}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return f"{base_url}/{plural_entity}/{entity_id}/stats"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def fetch_stats_data(
|
|
318
|
+
api: "SnapchatAdsAPI",
|
|
319
|
+
url: str,
|
|
320
|
+
params: dict,
|
|
321
|
+
granularity: str,
|
|
322
|
+
) -> Iterator[dict]:
|
|
323
|
+
client = create_client()
|
|
324
|
+
headers = api.get_headers()
|
|
325
|
+
|
|
326
|
+
response = client.get(url, headers=headers, params=params)
|
|
327
|
+
if not response.ok:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"Stats request failed: {response.status_code} - {response.text}"
|
|
330
|
+
)
|
|
331
|
+
response.raise_for_status()
|
|
332
|
+
|
|
333
|
+
result = response.json()
|
|
334
|
+
|
|
335
|
+
if result.get("request_status", "").upper() != "SUCCESS":
|
|
336
|
+
raise ValueError(f"Request failed: {result.get('request_status')} - {result}")
|
|
337
|
+
|
|
338
|
+
# Parse based on granularity
|
|
339
|
+
if granularity in ["TOTAL", "LIFETIME"]:
|
|
340
|
+
yield from parse_total_stats(result)
|
|
341
|
+
else: # DAY or HOUR
|
|
342
|
+
yield from parse_timeseries_stats(result)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def parse_total_stats(result: dict) -> Iterator[dict]:
|
|
346
|
+
"""
|
|
347
|
+
Parse TOTAL or LIFETIME granularity stats response.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
result: API response JSON
|
|
351
|
+
|
|
352
|
+
Yields:
|
|
353
|
+
Flattened stats records
|
|
354
|
+
"""
|
|
355
|
+
# Handle both total_stats and lifetime_stats response formats
|
|
356
|
+
total_stats = result.get("total_stats", []) or result.get("lifetime_stats", [])
|
|
357
|
+
|
|
358
|
+
for stat_item in total_stats:
|
|
359
|
+
if stat_item.get("sub_request_status", "").upper() == "SUCCESS":
|
|
360
|
+
# Handle both total_stat and lifetime_stat keys
|
|
361
|
+
total_stat = stat_item.get("total_stat", {}) or stat_item.get(
|
|
362
|
+
"lifetime_stat", {}
|
|
363
|
+
)
|
|
364
|
+
if total_stat:
|
|
365
|
+
# Flatten the stats object
|
|
366
|
+
record = {
|
|
367
|
+
"id": total_stat.get("id"),
|
|
368
|
+
"type": total_stat.get("type"),
|
|
369
|
+
**build_metadata_fields(total_stat),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Flatten nested stats
|
|
373
|
+
stats = total_stat.get("stats", {})
|
|
374
|
+
for key, value in stats.items():
|
|
375
|
+
record[key] = value
|
|
376
|
+
|
|
377
|
+
# Handle breakdown_stats if present
|
|
378
|
+
breakdown_stats = total_stat.get("breakdown_stats", {})
|
|
379
|
+
|
|
380
|
+
if breakdown_stats:
|
|
381
|
+
# Yield breakdown data when no dimension
|
|
382
|
+
for breakdown_type, breakdown_items in breakdown_stats.items():
|
|
383
|
+
for item in breakdown_items:
|
|
384
|
+
breakdown_record: dict = {}
|
|
385
|
+
|
|
386
|
+
# Add semantic entity fields (parent + breakdown)
|
|
387
|
+
add_semantic_entity_fields(
|
|
388
|
+
breakdown_record,
|
|
389
|
+
record["type"],
|
|
390
|
+
record["id"],
|
|
391
|
+
breakdown_type,
|
|
392
|
+
item.get("id"),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Add metadata fields
|
|
396
|
+
metadata = build_metadata_fields(record)
|
|
397
|
+
breakdown_record.update(metadata)
|
|
398
|
+
|
|
399
|
+
# Add stats
|
|
400
|
+
item_stats = item.get("stats", {})
|
|
401
|
+
for key, value in item_stats.items():
|
|
402
|
+
breakdown_record[key] = value
|
|
403
|
+
|
|
404
|
+
yield normalize_stats_record(breakdown_record)
|
|
405
|
+
else:
|
|
406
|
+
# No breakdown or dimension - yield parent record
|
|
407
|
+
# Convert generic 'id' to semantic name for consistency
|
|
408
|
+
parent_field_name = f"{record['type'].lower()}_id"
|
|
409
|
+
record[parent_field_name] = record.pop("id")
|
|
410
|
+
record.pop("type", None) # Remove type field as it's redundant now
|
|
411
|
+
yield normalize_stats_record(record)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_timeseries_stats(result: dict) -> Iterator[dict]:
|
|
415
|
+
"""
|
|
416
|
+
Parse DAY or HOUR granularity stats response.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
result: API response JSON
|
|
420
|
+
|
|
421
|
+
Yields:
|
|
422
|
+
Flattened stats records for each time period
|
|
423
|
+
"""
|
|
424
|
+
timeseries_stats = result.get("timeseries_stats", [])
|
|
425
|
+
|
|
426
|
+
for stat_item in timeseries_stats:
|
|
427
|
+
timeseries_stat = stat_item.get("timeseries_stat", {})
|
|
428
|
+
if timeseries_stat:
|
|
429
|
+
entity_id = timeseries_stat.get("id")
|
|
430
|
+
entity_type = timeseries_stat.get("type")
|
|
431
|
+
|
|
432
|
+
# Handle breakdown_stats if present in timeseries
|
|
433
|
+
breakdown_stats = timeseries_stat.get("breakdown_stats", {})
|
|
434
|
+
|
|
435
|
+
if breakdown_stats:
|
|
436
|
+
# Yield only breakdown data when breakdown is present
|
|
437
|
+
for breakdown_type, breakdown_items in breakdown_stats.items():
|
|
438
|
+
for item in breakdown_items:
|
|
439
|
+
item_timeseries = item.get("timeseries", [])
|
|
440
|
+
for period in item_timeseries:
|
|
441
|
+
breakdown_record: dict = {}
|
|
442
|
+
|
|
443
|
+
# Add semantic entity fields (parent + breakdown)
|
|
444
|
+
add_semantic_entity_fields(
|
|
445
|
+
breakdown_record,
|
|
446
|
+
entity_type,
|
|
447
|
+
entity_id,
|
|
448
|
+
breakdown_type,
|
|
449
|
+
item.get("id"),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Add metadata fields
|
|
453
|
+
metadata = build_metadata_fields(
|
|
454
|
+
timeseries_stat,
|
|
455
|
+
start_time=period.get("start_time"),
|
|
456
|
+
end_time=period.get("end_time"),
|
|
457
|
+
)
|
|
458
|
+
breakdown_record.update(metadata)
|
|
459
|
+
|
|
460
|
+
# Add stats
|
|
461
|
+
item_stats = period.get("stats", {})
|
|
462
|
+
for key, value in item_stats.items():
|
|
463
|
+
breakdown_record[key] = value
|
|
464
|
+
|
|
465
|
+
yield normalize_stats_record(breakdown_record)
|
|
466
|
+
else:
|
|
467
|
+
# Yield parent entity data when no breakdown or dimension
|
|
468
|
+
timeseries = timeseries_stat.get("timeseries", [])
|
|
469
|
+
for period in timeseries:
|
|
470
|
+
record: dict = {}
|
|
471
|
+
|
|
472
|
+
# Add semantic entity field (parent only)
|
|
473
|
+
add_semantic_entity_fields(record, entity_type, entity_id)
|
|
474
|
+
|
|
475
|
+
# Add metadata fields
|
|
476
|
+
metadata = build_metadata_fields(
|
|
477
|
+
timeseries_stat,
|
|
478
|
+
start_time=period.get("start_time"),
|
|
479
|
+
end_time=period.get("end_time"),
|
|
480
|
+
)
|
|
481
|
+
record.update(metadata)
|
|
482
|
+
|
|
483
|
+
# Flatten nested stats
|
|
484
|
+
stats = period.get("stats", {})
|
|
485
|
+
for key, value in stats.items():
|
|
486
|
+
record[key] = value
|
|
487
|
+
|
|
488
|
+
yield normalize_stats_record(record)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def fetch_entity_stats(
|
|
492
|
+
api: "SnapchatAdsAPI",
|
|
493
|
+
entity_type: str,
|
|
494
|
+
ad_account_id: list[str] | None,
|
|
495
|
+
organization_id: str | None,
|
|
496
|
+
base_url: str,
|
|
497
|
+
params: dict,
|
|
498
|
+
granularity: str,
|
|
499
|
+
start_date=None,
|
|
500
|
+
end_date=None,
|
|
501
|
+
) -> Iterator[dict]:
|
|
502
|
+
# Get account IDs
|
|
503
|
+
account_ids = get_account_ids(
|
|
504
|
+
api, ad_account_id, organization_id, base_url, "stats", start_date, end_date
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if not account_ids:
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
if entity_type == "adaccount":
|
|
511
|
+
# For ad accounts, fetch stats directly for each account
|
|
512
|
+
for account_id in account_ids:
|
|
513
|
+
url = f"{base_url}/adaccounts/{account_id}/stats"
|
|
514
|
+
yield from fetch_stats_data(api, url, params, granularity)
|
|
515
|
+
else:
|
|
516
|
+
# For campaign, adsquad, ad - first fetch entities, then stats
|
|
517
|
+
# Build resource_name from ENTITY_TYPE_MAP and item_key from entity_type
|
|
518
|
+
resource_name = ENTITY_TYPE_MAP.get(entity_type)
|
|
519
|
+
if not resource_name:
|
|
520
|
+
raise ValueError(f"Invalid entity_type: {entity_type}")
|
|
521
|
+
|
|
522
|
+
item_key = entity_type
|
|
523
|
+
client = create_client()
|
|
524
|
+
headers = api.get_headers()
|
|
525
|
+
|
|
526
|
+
for account_id in account_ids:
|
|
527
|
+
url = f"{base_url}/adaccounts/{account_id}/{resource_name}"
|
|
528
|
+
|
|
529
|
+
for result in paginate(client, headers, url, page_size=1000):
|
|
530
|
+
items_data = result.get(resource_name, [])
|
|
531
|
+
|
|
532
|
+
for item in items_data:
|
|
533
|
+
if item.get("sub_request_status", "").upper() == "SUCCESS":
|
|
534
|
+
data = item.get(item_key, {})
|
|
535
|
+
if data and data.get("id"):
|
|
536
|
+
entity_id = data["id"]
|
|
537
|
+
stats_url = build_stats_url(
|
|
538
|
+
base_url, entity_type, entity_id
|
|
539
|
+
)
|
|
540
|
+
yield from fetch_stats_data(
|
|
541
|
+
api, stats_url, params, granularity
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def parse_stats_table(table: str) -> ParsedStatsTable:
|
|
546
|
+
"""Parse stats table string into ParsedStatsTable.
|
|
547
|
+
|
|
548
|
+
Format: <resource-name>:<dimension-like-values>:<metrics>
|
|
549
|
+
|
|
550
|
+
Examples:
|
|
551
|
+
campaigns_stats:DAY:impressions,spend
|
|
552
|
+
campaigns_stats:campaign,DAY:impressions,spend
|
|
553
|
+
campaigns_stats:campaign,DAY,GEO,country:impressions,spend
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
table: Table string in the format above
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
ParsedStatsTable with categorized parameters
|
|
560
|
+
|
|
561
|
+
Raises:
|
|
562
|
+
ValueError: If granularity is missing or format is invalid
|
|
563
|
+
"""
|
|
564
|
+
from omniload.src.snapchat_ads.settings import (
|
|
565
|
+
DEFAULT_STATS_FIELDS,
|
|
566
|
+
VALID_BREAKDOWNS,
|
|
567
|
+
VALID_DIMENSIONS,
|
|
568
|
+
VALID_GRANULARITIES,
|
|
569
|
+
VALID_PIVOTS,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
parts = table.split(":")
|
|
573
|
+
resource_name = parts[0]
|
|
574
|
+
|
|
575
|
+
if len(parts) < 2:
|
|
576
|
+
raise ValueError(
|
|
577
|
+
f"Parameters required for stats table. "
|
|
578
|
+
f"Format: {resource_name}:<dimension-like-values>:<metrics>"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# Parse dimension-like values (part 1)
|
|
582
|
+
dimension_params = [p.strip() for p in parts[1].split(",")]
|
|
583
|
+
|
|
584
|
+
# Categorize each parameter without depending on order
|
|
585
|
+
granularity: str | None = None
|
|
586
|
+
breakdown: str | None = None
|
|
587
|
+
dimension: str | None = None
|
|
588
|
+
pivot: str | None = None
|
|
589
|
+
|
|
590
|
+
for param in dimension_params:
|
|
591
|
+
param_upper = param.upper()
|
|
592
|
+
param_lower = param.lower()
|
|
593
|
+
|
|
594
|
+
if param_upper in VALID_GRANULARITIES:
|
|
595
|
+
granularity = param_upper
|
|
596
|
+
elif param_lower in VALID_BREAKDOWNS:
|
|
597
|
+
breakdown = param_lower
|
|
598
|
+
elif param_upper in VALID_DIMENSIONS:
|
|
599
|
+
dimension = param_upper
|
|
600
|
+
elif param_lower in VALID_PIVOTS:
|
|
601
|
+
pivot = param_lower
|
|
602
|
+
else:
|
|
603
|
+
raise ValueError(
|
|
604
|
+
f"Unknown parameter '{param}'. Must be a granularity "
|
|
605
|
+
f"({', '.join(VALID_GRANULARITIES)}), breakdown "
|
|
606
|
+
f"({', '.join(VALID_BREAKDOWNS)}), dimension "
|
|
607
|
+
f"({', '.join(VALID_DIMENSIONS)}), or pivot "
|
|
608
|
+
f"({', '.join(VALID_PIVOTS)})"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
if not granularity:
|
|
612
|
+
raise ValueError(
|
|
613
|
+
f"Granularity is required. "
|
|
614
|
+
f"Format: {resource_name}:<dimension-like-values>:<metrics>"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Parse metrics (part 2) or use defaults
|
|
618
|
+
if len(parts) >= 3 and parts[2].strip():
|
|
619
|
+
fields = parts[2].strip()
|
|
620
|
+
else:
|
|
621
|
+
fields = DEFAULT_STATS_FIELDS
|
|
622
|
+
|
|
623
|
+
return ParsedStatsTable(
|
|
624
|
+
resource_name=resource_name,
|
|
625
|
+
granularity=granularity,
|
|
626
|
+
fields=fields,
|
|
627
|
+
breakdown=breakdown,
|
|
628
|
+
dimension=dimension,
|
|
629
|
+
pivot=pivot,
|
|
630
|
+
)
|