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