apple-search-ads-client 1.0.0__py3-none-any.whl → 1.0.3__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.

Potentially problematic release.


This version of apple-search-ads-client might be problematic. Click here for more details.

@@ -10,4 +10,4 @@ __version__ = "0.1.0"
10
10
  __author__ = "Your Name"
11
11
  __email__ = "your.email@example.com"
12
12
 
13
- __all__ = ["AppleSearchAdsClient"]
13
+ __all__ = ["AppleSearchAdsClient"]
@@ -9,7 +9,7 @@ import time
9
9
  import os
10
10
  import requests
11
11
  from datetime import datetime, timedelta
12
- from typing import Dict, List, Optional, Any, Union
12
+ from typing import Dict, List, Optional, Union, Any
13
13
  import pandas as pd
14
14
  from ratelimit import limits, sleep_and_retry
15
15
 
@@ -17,18 +17,18 @@ from ratelimit import limits, sleep_and_retry
17
17
  class AppleSearchAdsClient:
18
18
  """
19
19
  Client for Apple Search Ads API v5.
20
-
20
+
21
21
  This client provides methods to interact with Apple Search Ads API,
22
22
  including campaign management, reporting, and spend tracking.
23
-
23
+
24
24
  Args:
25
25
  client_id: Apple Search Ads client ID
26
- team_id: Apple Search Ads team ID
26
+ team_id: Apple Search Ads team ID
27
27
  key_id: Apple Search Ads key ID
28
28
  private_key_path: Path to the private key .p8 file
29
29
  private_key_content: Private key content as string (alternative to file path)
30
30
  org_id: Optional organization ID (will be fetched automatically if not provided)
31
-
31
+
32
32
  Example:
33
33
  >>> client = AppleSearchAdsClient(
34
34
  ... client_id="your_client_id",
@@ -38,9 +38,9 @@ class AppleSearchAdsClient:
38
38
  ... )
39
39
  >>> campaigns = client.get_campaigns()
40
40
  """
41
-
41
+
42
42
  BASE_URL = "https://api.searchads.apple.com/api/v5"
43
-
43
+
44
44
  def __init__(
45
45
  self,
46
46
  client_id: Optional[str] = None,
@@ -48,185 +48,181 @@ class AppleSearchAdsClient:
48
48
  key_id: Optional[str] = None,
49
49
  private_key_path: Optional[str] = None,
50
50
  private_key_content: Optional[str] = None,
51
- org_id: Optional[str] = None
51
+ org_id: Optional[str] = None,
52
52
  ):
53
53
  # Try to get credentials from parameters, then environment variables
54
- self.client_id = client_id or os.environ.get('APPLE_SEARCH_ADS_CLIENT_ID')
55
- self.team_id = team_id or os.environ.get('APPLE_SEARCH_ADS_TEAM_ID')
56
- self.key_id = key_id or os.environ.get('APPLE_SEARCH_ADS_KEY_ID')
57
- self.private_key_path = private_key_path or os.environ.get('APPLE_SEARCH_ADS_PRIVATE_KEY_PATH')
54
+ self.client_id = client_id or os.environ.get("APPLE_SEARCH_ADS_CLIENT_ID")
55
+ self.team_id = team_id or os.environ.get("APPLE_SEARCH_ADS_TEAM_ID")
56
+ self.key_id = key_id or os.environ.get("APPLE_SEARCH_ADS_KEY_ID")
57
+ self.private_key_path = private_key_path or os.environ.get(
58
+ "APPLE_SEARCH_ADS_PRIVATE_KEY_PATH"
59
+ )
58
60
  self.private_key_content = private_key_content
59
-
61
+
60
62
  # Validate required credentials
61
63
  if not all([self.client_id, self.team_id, self.key_id]):
62
64
  raise ValueError(
63
65
  "Missing required credentials. Please provide client_id, team_id, and key_id "
64
66
  "either as parameters or environment variables."
65
67
  )
66
-
68
+
67
69
  if not self.private_key_path and not self.private_key_content:
68
70
  raise ValueError(
69
71
  "Missing private key. Please provide either private_key_path or private_key_content."
70
72
  )
71
-
73
+
72
74
  self.org_id = org_id
73
- self._token = None
74
- self._token_expiry = None
75
-
75
+ self._token: Optional[str] = None
76
+ self._token_expiry: Optional[float] = None
77
+
76
78
  def _load_private_key(self) -> str:
77
79
  """Load private key from file or content."""
78
80
  if self.private_key_content:
79
81
  return self.private_key_content
80
-
82
+
83
+ if not self.private_key_path:
84
+ raise ValueError("No private key path provided")
85
+
81
86
  if not os.path.exists(self.private_key_path):
82
87
  raise FileNotFoundError(f"Private key file not found: {self.private_key_path}")
83
-
84
- with open(self.private_key_path, 'r') as f:
88
+
89
+ with open(self.private_key_path, "r") as f:
85
90
  return f.read()
86
-
91
+
87
92
  def _generate_client_secret(self) -> str:
88
93
  """Generate client secret JWT for Apple Search Ads."""
89
94
  # Token expires in 180 days (max allowed by Apple)
90
95
  expiry = int(time.time() + 86400 * 180)
91
-
96
+
92
97
  payload = {
93
98
  "sub": self.client_id,
94
99
  "aud": "https://appleid.apple.com",
95
100
  "iat": int(time.time()),
96
101
  "exp": expiry,
97
- "iss": self.team_id
98
- }
99
-
100
- headers = {
101
- "alg": "ES256",
102
- "kid": self.key_id
102
+ "iss": self.team_id,
103
103
  }
104
-
104
+
105
+ headers = {"alg": "ES256", "kid": self.key_id}
106
+
105
107
  private_key = self._load_private_key()
106
-
107
- return jwt.encode(
108
- payload,
109
- private_key,
110
- algorithm="ES256",
111
- headers=headers
112
- )
113
-
108
+
109
+ return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
110
+
114
111
  def _get_access_token(self) -> str:
115
112
  """Get access token using client credentials flow."""
116
113
  if self._token and self._token_expiry and time.time() < self._token_expiry:
117
114
  return self._token
118
-
115
+
119
116
  token_url = "https://appleid.apple.com/auth/oauth2/token"
120
-
117
+
121
118
  data = {
122
119
  "grant_type": "client_credentials",
123
120
  "client_id": self.client_id,
124
121
  "client_secret": self._generate_client_secret(),
125
- "scope": "searchadsorg"
122
+ "scope": "searchadsorg",
126
123
  }
127
-
124
+
128
125
  response = requests.post(token_url, data=data)
129
126
  response.raise_for_status()
130
-
127
+
131
128
  token_data = response.json()
132
129
  self._token = token_data["access_token"]
133
130
  # Token expires in 1 hour, refresh 5 minutes before
134
131
  self._token_expiry = time.time() + 3300
135
-
132
+
133
+ if self._token is None:
134
+ raise ValueError("Failed to obtain access token")
136
135
  return self._token
137
-
136
+
138
137
  def _get_headers(self, include_org_context: bool = True) -> Dict[str, str]:
139
138
  """Get headers for API requests."""
140
139
  headers = {
141
140
  "Authorization": f"Bearer {self._get_access_token()}",
142
- "Content-Type": "application/json"
141
+ "Content-Type": "application/json",
143
142
  }
144
-
143
+
145
144
  # Add organization context if we have it (not needed for ACLs endpoint)
146
145
  if include_org_context and self.org_id:
147
146
  headers["X-AP-Context"] = f"orgId={self.org_id}"
148
-
147
+
149
148
  return headers
150
-
149
+
151
150
  def _get_org_id(self) -> str:
152
151
  """Get the organization ID."""
153
152
  if self.org_id:
154
153
  return self.org_id
155
-
154
+
156
155
  response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
157
-
156
+
158
157
  if response and "data" in response and len(response["data"]) > 0:
159
158
  self.org_id = str(response["data"][0]["orgId"])
160
159
  return self.org_id
161
-
160
+
162
161
  raise ValueError("No organization found for this account")
163
-
162
+
164
163
  @sleep_and_retry
165
164
  @limits(calls=10, period=1) # Apple Search Ads rate limit
166
165
  def _make_request(
167
- self,
168
- url: str,
169
- method: str = "GET",
170
- json_data: Optional[Dict] = None,
166
+ self,
167
+ url: str,
168
+ method: str = "GET",
169
+ json_data: Optional[Dict] = None,
171
170
  params: Optional[Dict] = None,
172
- include_org_context: bool = True
173
- ) -> Dict:
171
+ include_org_context: bool = True,
172
+ ) -> Dict[str, Any]:
174
173
  """Make a rate-limited request to the API."""
175
174
  response = requests.request(
176
175
  method=method,
177
176
  url=url,
178
177
  headers=self._get_headers(include_org_context=include_org_context),
179
178
  json=json_data,
180
- params=params
179
+ params=params,
181
180
  )
182
181
  response.raise_for_status()
183
182
  return response.json()
184
-
185
- def get_all_organizations(self) -> List[Dict]:
183
+
184
+ def get_all_organizations(self) -> List[Dict[str, Any]]:
186
185
  """
187
186
  Get all organizations the user has access to.
188
-
187
+
189
188
  Returns:
190
189
  List of organization dictionaries containing orgId, orgName, etc.
191
190
  """
192
191
  response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
193
-
192
+
194
193
  if response and "data" in response:
195
194
  return response["data"]
196
-
195
+
197
196
  return []
198
-
199
-
200
- def get_adgroups(self, campaign_id: str) -> List[Dict]:
197
+
198
+ def get_adgroups(self, campaign_id: str) -> List[Dict[str, Any]]:
201
199
  """
202
200
  Get all ad groups for a specific campaign.
203
-
201
+
204
202
  Args:
205
203
  campaign_id: The campaign ID to get ad groups for
206
-
204
+
207
205
  Returns:
208
206
  List of ad group dictionaries
209
207
  """
210
208
  # Ensure we have org_id for the context header
211
209
  if not self.org_id:
212
210
  self._get_org_id()
213
-
211
+
214
212
  url = f"{self.BASE_URL}/campaigns/{campaign_id}/adgroups"
215
-
216
- params = {
217
- "limit": 1000
218
- }
219
-
213
+
214
+ params = {"limit": 1000}
215
+
220
216
  response = self._make_request(url, params=params)
221
217
  return response.get("data", [])
222
-
223
- def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict]:
218
+
219
+ def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict[str, Any]]:
224
220
  """
225
221
  Get all campaigns for a specific organization or the default one.
226
-
222
+
227
223
  Args:
228
224
  org_id: Optional organization ID. If not provided, uses the default org.
229
-
225
+
230
226
  Returns:
231
227
  List of campaign dictionaries.
232
228
  """
@@ -237,84 +233,82 @@ class AppleSearchAdsClient:
237
233
  self.org_id = str(org_id)
238
234
  elif not self.org_id:
239
235
  self._get_org_id()
240
-
236
+
241
237
  url = f"{self.BASE_URL}/campaigns"
242
-
243
- params = {
244
- "limit": 1000
245
- }
246
-
238
+
239
+ params = {"limit": 1000}
240
+
247
241
  try:
248
242
  response = self._make_request(url, params=params)
249
243
  campaigns = response.get("data", [])
250
-
244
+
251
245
  # Add org_id to each campaign for tracking
252
246
  for campaign in campaigns:
253
247
  campaign["fetched_org_id"] = self.org_id
254
-
248
+
255
249
  return campaigns
256
250
  finally:
257
251
  # Restore original org_id if we changed it
258
252
  if original_org_id is not None:
259
253
  self.org_id = original_org_id
260
-
261
- def get_all_campaigns(self) -> List[Dict]:
254
+
255
+ def get_all_campaigns(self) -> List[Dict[str, Any]]:
262
256
  """
263
257
  Get campaigns from all organizations.
264
-
258
+
265
259
  Returns:
266
260
  List of all campaigns across all organizations.
267
261
  """
268
262
  all_campaigns = []
269
263
  organizations = self.get_all_organizations()
270
-
264
+
271
265
  for org in organizations:
272
266
  org_id = str(org["orgId"])
273
267
  org_name = org.get("orgName", "Unknown")
274
-
268
+
275
269
  try:
276
270
  campaigns = self.get_campaigns(org_id=org_id)
277
-
271
+
278
272
  # Add organization info to each campaign
279
273
  for campaign in campaigns:
280
274
  campaign["org_name"] = org_name
281
275
  campaign["parent_org_id"] = org.get("parentOrgId")
282
-
276
+
283
277
  all_campaigns.extend(campaigns)
284
278
  except Exception as e:
285
279
  print(f"Error fetching campaigns from {org_name}: {e}")
286
-
280
+
287
281
  return all_campaigns
288
-
282
+
289
283
  def get_campaign_report(
290
- self,
291
- start_date: Union[datetime, str],
284
+ self,
285
+ start_date: Union[datetime, str],
292
286
  end_date: Union[datetime, str],
293
- granularity: str = "DAILY"
287
+ granularity: str = "DAILY",
294
288
  ) -> pd.DataFrame:
295
289
  """
296
290
  Get campaign performance report.
297
-
291
+
298
292
  Args:
299
293
  start_date: Start date for the report (datetime or YYYY-MM-DD string)
300
294
  end_date: End date for the report (datetime or YYYY-MM-DD string)
301
295
  granularity: DAILY, WEEKLY, or MONTHLY
302
-
296
+
303
297
  Returns:
304
298
  DataFrame with campaign performance metrics.
305
299
  """
306
300
  # Ensure we have org_id for the context header
307
301
  if not self.org_id:
308
302
  self._get_org_id()
309
-
303
+
310
304
  # Convert string dates to datetime if needed
311
305
  if isinstance(start_date, str):
312
306
  start_date = datetime.strptime(start_date, "%Y-%m-%d")
313
307
  if isinstance(end_date, str):
314
308
  end_date = datetime.strptime(end_date, "%Y-%m-%d")
315
-
309
+
316
310
  url = f"{self.BASE_URL}/reports/campaigns"
317
-
311
+
318
312
  # Apple Search Ads API uses date-only format
319
313
  request_data = {
320
314
  "startTime": start_date.strftime("%Y-%m-%d"),
@@ -322,130 +316,133 @@ class AppleSearchAdsClient:
322
316
  "granularity": granularity,
323
317
  "selector": {
324
318
  "orderBy": [{"field": "localSpend", "sortOrder": "DESCENDING"}],
325
- "pagination": {"limit": 1000}
319
+ "pagination": {"limit": 1000},
326
320
  },
327
321
  "returnRowTotals": True,
328
- "returnRecordsWithNoMetrics": False
322
+ "returnRecordsWithNoMetrics": False,
329
323
  }
330
-
331
- response = self._make_request(
332
- url,
333
- method="POST",
334
- json_data=request_data
335
- )
336
-
324
+
325
+ response = self._make_request(url, method="POST", json_data=request_data)
326
+
337
327
  # Handle different response structures
338
328
  rows = []
339
329
  if response and "data" in response:
340
- if "reportingDataResponse" in response["data"] and "row" in response["data"]["reportingDataResponse"]:
330
+ if (
331
+ "reportingDataResponse" in response["data"]
332
+ and "row" in response["data"]["reportingDataResponse"]
333
+ ):
341
334
  # New response structure
342
335
  rows = response["data"]["reportingDataResponse"]["row"]
343
336
  elif "rows" in response["data"]:
344
337
  # Old response structure
345
338
  rows = response["data"]["rows"]
346
-
339
+
347
340
  if rows:
348
341
  # Extract data into a flat structure
349
342
  data = []
350
343
  for row in rows:
351
344
  metadata = row.get("metadata", {})
352
-
345
+
353
346
  # For the new structure, we need to process granularity data
354
347
  if "granularity" in row:
355
348
  # New structure: iterate through each day in granularity
356
349
  for day_data in row["granularity"]:
357
- data.append({
358
- "date": day_data.get("date"),
350
+ data.append(
351
+ {
352
+ "date": day_data.get("date"),
353
+ "campaign_id": metadata.get("campaignId"),
354
+ "campaign_name": metadata.get("campaignName"),
355
+ "campaign_status": metadata.get("campaignStatus"),
356
+ "app_name": (
357
+ metadata.get("app", {}).get("appName")
358
+ if "app" in metadata
359
+ else metadata.get("appName")
360
+ ),
361
+ "adam_id": metadata.get("adamId"),
362
+ "impressions": day_data.get("impressions", 0),
363
+ "taps": day_data.get("taps", 0),
364
+ "installs": day_data.get("totalInstalls", 0),
365
+ "spend": float(day_data.get("localSpend", {}).get("amount", 0)),
366
+ "currency": day_data.get("localSpend", {}).get("currency", "USD"),
367
+ "avg_cpa": float(day_data.get("totalAvgCPI", {}).get("amount", 0)),
368
+ "avg_cpt": float(day_data.get("avgCPT", {}).get("amount", 0)),
369
+ "ttr": day_data.get("ttr", 0),
370
+ "conversion_rate": day_data.get("totalInstallRate", 0),
371
+ }
372
+ )
373
+ else:
374
+ # Old structure
375
+ metrics = row.get("metrics", {})
376
+
377
+ data.append(
378
+ {
379
+ "date": metadata.get("date"),
359
380
  "campaign_id": metadata.get("campaignId"),
360
381
  "campaign_name": metadata.get("campaignName"),
361
382
  "campaign_status": metadata.get("campaignStatus"),
362
- "app_name": metadata.get("app", {}).get("appName") if "app" in metadata else metadata.get("appName"),
383
+ "app_name": metadata.get("appName"),
363
384
  "adam_id": metadata.get("adamId"),
364
- "impressions": day_data.get("impressions", 0),
365
- "taps": day_data.get("taps", 0),
366
- "installs": day_data.get("totalInstalls", 0),
367
- "spend": float(day_data.get("localSpend", {}).get("amount", 0)),
368
- "currency": day_data.get("localSpend", {}).get("currency", "USD"),
369
- "avg_cpa": float(day_data.get("totalAvgCPI", {}).get("amount", 0)),
370
- "avg_cpt": float(day_data.get("avgCPT", {}).get("amount", 0)),
371
- "ttr": day_data.get("ttr", 0),
372
- "conversion_rate": day_data.get("totalInstallRate", 0)
373
- })
374
- else:
375
- # Old structure
376
- metrics = row.get("metrics", {})
377
-
378
- data.append({
379
- "date": metadata.get("date"),
380
- "campaign_id": metadata.get("campaignId"),
381
- "campaign_name": metadata.get("campaignName"),
382
- "campaign_status": metadata.get("campaignStatus"),
383
- "app_name": metadata.get("appName"),
384
- "adam_id": metadata.get("adamId"),
385
- "impressions": metrics.get("impressions", 0),
386
- "taps": metrics.get("taps", 0),
387
- "installs": metrics.get("installs", 0),
388
- "spend": float(metrics.get("localSpend", {}).get("amount", 0)),
389
- "currency": metrics.get("localSpend", {}).get("currency", "USD"),
390
- "avg_cpa": float(metrics.get("avgCPA", {}).get("amount", 0)),
391
- "avg_cpt": float(metrics.get("avgCPT", {}).get("amount", 0)),
392
- "ttr": metrics.get("ttr", 0),
393
- "conversion_rate": metrics.get("conversionRate", 0)
394
- })
395
-
385
+ "impressions": metrics.get("impressions", 0),
386
+ "taps": metrics.get("taps", 0),
387
+ "installs": metrics.get("installs", 0),
388
+ "spend": float(metrics.get("localSpend", {}).get("amount", 0)),
389
+ "currency": metrics.get("localSpend", {}).get("currency", "USD"),
390
+ "avg_cpa": float(metrics.get("avgCPA", {}).get("amount", 0)),
391
+ "avg_cpt": float(metrics.get("avgCPT", {}).get("amount", 0)),
392
+ "ttr": metrics.get("ttr", 0),
393
+ "conversion_rate": metrics.get("conversionRate", 0),
394
+ }
395
+ )
396
+
396
397
  return pd.DataFrame(data)
397
-
398
+
398
399
  return pd.DataFrame()
399
-
400
- def get_daily_spend(
401
- self,
402
- days: int = 30,
403
- fetch_all_orgs: bool = True
404
- ) -> pd.DataFrame:
400
+
401
+ def get_daily_spend(self, days: int = 30, fetch_all_orgs: bool = True) -> pd.DataFrame:
405
402
  """
406
403
  Get daily spend across all campaigns.
407
-
404
+
408
405
  Args:
409
406
  days: Number of days to fetch
410
407
  fetch_all_orgs: If True, fetches from all organizations
411
-
408
+
412
409
  Returns:
413
410
  DataFrame with daily spend metrics.
414
411
  """
415
412
  end_date = datetime.now()
416
413
  start_date = end_date - timedelta(days=days)
417
-
414
+
418
415
  return self.get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs)
419
-
416
+
420
417
  def get_daily_spend_with_dates(
421
- self,
422
- start_date: Union[datetime, str],
423
- end_date: Union[datetime, str],
424
- fetch_all_orgs: bool = True
418
+ self,
419
+ start_date: Union[datetime, str],
420
+ end_date: Union[datetime, str],
421
+ fetch_all_orgs: bool = True,
425
422
  ) -> pd.DataFrame:
426
423
  """
427
424
  Get daily spend across all campaigns for a specific date range.
428
-
425
+
429
426
  Args:
430
427
  start_date: Start date for the report
431
428
  end_date: End date for the report
432
429
  fetch_all_orgs: If True, fetches from all organizations
433
-
430
+
434
431
  Returns:
435
432
  DataFrame with daily spend metrics.
436
433
  """
437
434
  all_campaign_data = []
438
-
435
+
439
436
  if fetch_all_orgs:
440
437
  organizations = self.get_all_organizations()
441
-
438
+
442
439
  for org in organizations:
443
440
  org_id = str(org["orgId"])
444
-
441
+
445
442
  # Set org context
446
443
  current_org_id = self.org_id
447
444
  self.org_id = org_id
448
-
445
+
449
446
  try:
450
447
  # Get campaign report for this org
451
448
  org_campaign_df = self.get_campaign_report(start_date, end_date)
@@ -460,47 +457,42 @@ class AppleSearchAdsClient:
460
457
  campaign_df = self.get_campaign_report(start_date, end_date)
461
458
  if not campaign_df.empty:
462
459
  all_campaign_data.append(campaign_df)
463
-
460
+
464
461
  if not all_campaign_data:
465
462
  return pd.DataFrame()
466
-
463
+
467
464
  # Combine all campaign data
468
465
  campaign_df = pd.concat(all_campaign_data, ignore_index=True)
469
-
466
+
470
467
  # Group by date
471
- daily_df = campaign_df.groupby('date').agg({
472
- 'spend': 'sum',
473
- 'impressions': 'sum',
474
- 'taps': 'sum',
475
- 'installs': 'sum'
476
- }).reset_index()
477
-
468
+ daily_df = (
469
+ campaign_df.groupby("date")
470
+ .agg({"spend": "sum", "impressions": "sum", "taps": "sum", "installs": "sum"})
471
+ .reset_index()
472
+ )
473
+
478
474
  # Calculate average metrics
479
- daily_df['avg_cpi'] = daily_df.apply(
480
- lambda row: row['spend'] / row['installs'] if row['installs'] > 0 else 0,
481
- axis=1
475
+ daily_df["avg_cpi"] = daily_df.apply(
476
+ lambda row: row["spend"] / row["installs"] if row["installs"] > 0 else 0, axis=1
482
477
  )
483
-
484
- daily_df['avg_cpt'] = daily_df.apply(
485
- lambda row: row['spend'] / row['taps'] if row['taps'] > 0 else 0,
486
- axis=1
478
+
479
+ daily_df["avg_cpt"] = daily_df.apply(
480
+ lambda row: row["spend"] / row["taps"] if row["taps"] > 0 else 0, axis=1
487
481
  )
488
-
489
- daily_df['conversion_rate'] = daily_df.apply(
490
- lambda row: row['installs'] / row['taps'] * 100 if row['taps'] > 0 else 0,
491
- axis=1
482
+
483
+ daily_df["conversion_rate"] = daily_df.apply(
484
+ lambda row: row["installs"] / row["taps"] * 100 if row["taps"] > 0 else 0, axis=1
492
485
  )
493
-
494
-
486
+
495
487
  return daily_df
496
-
497
- def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict]:
488
+
489
+ def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict[str, Any]]:
498
490
  """
499
491
  Get all campaigns with their app details including adamId.
500
-
492
+
501
493
  Args:
502
494
  fetch_all_orgs: If True, fetches from all organizations
503
-
495
+
504
496
  Returns:
505
497
  List of campaign dictionaries with app details.
506
498
  """
@@ -510,23 +502,23 @@ class AppleSearchAdsClient:
510
502
  if not self.org_id:
511
503
  self._get_org_id()
512
504
  campaigns = self.get_campaigns()
513
-
505
+
514
506
  return campaigns
515
-
507
+
516
508
  def get_daily_spend_by_app(
517
- self,
518
- start_date: Union[datetime, str],
519
- end_date: Union[datetime, str],
520
- fetch_all_orgs: bool = True
509
+ self,
510
+ start_date: Union[datetime, str],
511
+ end_date: Union[datetime, str],
512
+ fetch_all_orgs: bool = True,
521
513
  ) -> pd.DataFrame:
522
514
  """
523
515
  Get daily advertising spend grouped by app (adamId).
524
-
516
+
525
517
  Args:
526
518
  start_date: Start date for the report
527
519
  end_date: End date for the report
528
520
  fetch_all_orgs: If True, fetches from all organizations
529
-
521
+
530
522
  Returns:
531
523
  DataFrame with columns:
532
524
  - date: The date
@@ -540,21 +532,21 @@ class AppleSearchAdsClient:
540
532
  # First, get campaign-to-app mapping
541
533
  campaigns = self.get_campaigns_with_details(fetch_all_orgs=fetch_all_orgs)
542
534
  campaign_to_app = {str(c["id"]): str(c.get("adamId")) for c in campaigns if c.get("adamId")}
543
-
535
+
544
536
  # Get campaign reports from all organizations
545
537
  all_campaign_data = []
546
-
538
+
547
539
  if fetch_all_orgs:
548
540
  organizations = self.get_all_organizations()
549
-
541
+
550
542
  for org in organizations:
551
543
  org_id = str(org["orgId"])
552
544
  org_name = org.get("orgName", "Unknown")
553
-
545
+
554
546
  # Set org context
555
547
  current_org_id = self.org_id
556
548
  self.org_id = org_id
557
-
549
+
558
550
  try:
559
551
  # Get campaign report for this org
560
552
  org_campaign_df = self.get_campaign_report(start_date, end_date)
@@ -573,74 +565,75 @@ class AppleSearchAdsClient:
573
565
  campaign_df = self.get_campaign_report(start_date, end_date)
574
566
  if not campaign_df.empty:
575
567
  all_campaign_data.append(campaign_df)
576
-
568
+
577
569
  if not all_campaign_data:
578
570
  return pd.DataFrame()
579
-
571
+
580
572
  # Combine all campaign data
581
573
  campaign_df = pd.concat(all_campaign_data, ignore_index=True)
582
-
574
+
583
575
  # Convert campaign_id to string for mapping
584
576
  campaign_df["campaign_id"] = campaign_df["campaign_id"].astype(str)
585
-
577
+
586
578
  # Map campaigns to apps
587
579
  campaign_df["app_id"] = campaign_df["campaign_id"].map(campaign_to_app)
588
-
580
+
589
581
  # Filter out campaigns without app mapping
590
582
  app_df = campaign_df[campaign_df["app_id"].notna()].copy()
591
-
583
+
592
584
  if app_df.empty:
593
585
  return pd.DataFrame()
594
-
586
+
595
587
  # Aggregate by date and app
596
- aggregated = app_df.groupby(['date', 'app_id']).agg({
597
- 'spend': 'sum',
598
- 'impressions': 'sum',
599
- 'taps': 'sum',
600
- 'installs': 'sum',
601
- 'campaign_id': 'nunique'
602
- }).reset_index()
603
-
588
+ aggregated = (
589
+ app_df.groupby(["date", "app_id"])
590
+ .agg(
591
+ {
592
+ "spend": "sum",
593
+ "impressions": "sum",
594
+ "taps": "sum",
595
+ "installs": "sum",
596
+ "campaign_id": "nunique",
597
+ }
598
+ )
599
+ .reset_index()
600
+ )
601
+
604
602
  # Rename columns to match standard format
605
- aggregated.rename(columns={
606
- 'campaign_id': 'campaigns'
607
- }, inplace=True)
608
-
603
+ aggregated.rename(columns={"campaign_id": "campaigns"}, inplace=True)
604
+
609
605
  # Add derived metrics
610
- aggregated['cpi'] = aggregated.apply(
611
- lambda x: x['spend'] / x['installs'] if x['installs'] > 0 else 0,
612
- axis=1
606
+ aggregated["cpi"] = aggregated.apply(
607
+ lambda x: x["spend"] / x["installs"] if x["installs"] > 0 else 0, axis=1
613
608
  ).round(2)
614
-
615
- aggregated['ctr'] = aggregated.apply(
616
- lambda x: (x['taps'] / x['impressions'] * 100) if x['impressions'] > 0 else 0,
617
- axis=1
609
+
610
+ aggregated["ctr"] = aggregated.apply(
611
+ lambda x: (x["taps"] / x["impressions"] * 100) if x["impressions"] > 0 else 0, axis=1
618
612
  ).round(2)
619
-
620
- aggregated['cvr'] = aggregated.apply(
621
- lambda x: (x['installs'] / x['taps'] * 100) if x['taps'] > 0 else 0,
622
- axis=1
613
+
614
+ aggregated["cvr"] = aggregated.apply(
615
+ lambda x: (x["installs"] / x["taps"] * 100) if x["taps"] > 0 else 0, axis=1
623
616
  ).round(2)
624
-
617
+
625
618
  # Sort by date and app
626
- aggregated.sort_values(['date', 'app_id'], inplace=True)
627
-
619
+ aggregated.sort_values(["date", "app_id"], inplace=True)
620
+
628
621
  # Filter to ensure we only return data within the requested date range
629
622
  if isinstance(start_date, str):
630
623
  start_date = datetime.strptime(start_date, "%Y-%m-%d")
631
624
  if isinstance(end_date, str):
632
625
  end_date = datetime.strptime(end_date, "%Y-%m-%d")
633
-
634
- aggregated['date_dt'] = pd.to_datetime(aggregated['date'])
635
- start_date_only = start_date.date() if hasattr(start_date, 'date') else start_date
636
- end_date_only = end_date.date() if hasattr(end_date, 'date') else end_date
637
-
626
+
627
+ aggregated["date_dt"] = pd.to_datetime(aggregated["date"])
628
+ start_date_only = start_date.date() if hasattr(start_date, "date") else start_date
629
+ end_date_only = end_date.date() if hasattr(end_date, "date") else end_date
630
+
638
631
  aggregated = aggregated[
639
- (aggregated['date_dt'].dt.date >= start_date_only) &
640
- (aggregated['date_dt'].dt.date <= end_date_only)
632
+ (aggregated["date_dt"].dt.date >= start_date_only)
633
+ & (aggregated["date_dt"].dt.date <= end_date_only)
641
634
  ].copy()
642
-
635
+
643
636
  # Drop the temporary datetime column
644
- aggregated.drop('date_dt', axis=1, inplace=True)
645
-
646
- return aggregated
637
+ aggregated.drop("date_dt", axis=1, inplace=True)
638
+
639
+ return aggregated
@@ -5,29 +5,35 @@ Custom exceptions for Apple Search Ads Python Client.
5
5
 
6
6
  class AppleSearchAdsError(Exception):
7
7
  """Base exception for Apple Search Ads API errors."""
8
+
8
9
  pass
9
10
 
10
11
 
11
12
  class AuthenticationError(AppleSearchAdsError):
12
13
  """Raised when authentication fails."""
14
+
13
15
  pass
14
16
 
15
17
 
16
18
  class RateLimitError(AppleSearchAdsError):
17
19
  """Raised when API rate limit is exceeded."""
20
+
18
21
  pass
19
22
 
20
23
 
21
24
  class InvalidRequestError(AppleSearchAdsError):
22
25
  """Raised when the API request is invalid."""
26
+
23
27
  pass
24
28
 
25
29
 
26
30
  class OrganizationNotFoundError(AppleSearchAdsError):
27
31
  """Raised when no organization is found for the account."""
32
+
28
33
  pass
29
34
 
30
35
 
31
36
  class ConfigurationError(AppleSearchAdsError):
32
37
  """Raised when the client is not configured properly."""
33
- pass
38
+
39
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apple-search-ads-client
3
- Version: 1.0.0
3
+ Version: 1.0.3
4
4
  Summary: A Python client for Apple Search Ads API v5
5
5
  Home-page: https://github.com/bickster/apple-search-ads-python
6
6
  Author: Bickster LLC
@@ -335,8 +335,7 @@ pytest tests --cov=apple_search_ads --cov-report=html
335
335
  pytest tests/test_integration.py -v
336
336
  ```
337
337
 
338
- For detailed testing documentation, see [TESTING.md](TESTING.md).
339
- For integration testing setup, see [docs/integration-testing.md](docs/integration-testing.md).
338
+ For detailed testing documentation, see [TESTING.md](TESTING.md).
340
339
 
341
340
  ## Contributing
342
341
 
@@ -0,0 +1,8 @@
1
+ apple_search_ads/__init__.py,sha256=gTvCiXK29iuMwRW62N8zQk0xz5-gkKz__EULtusy1As,256
2
+ apple_search_ads/client.py,sha256=85-U8bv1YKxv9dPdzFLpvXD9lucpjFPzp15PKNzFe_M,23224
3
+ apple_search_ads/exceptions.py,sha256=Muyug8BUP3MMz3TJJaQUVquXBCdQ03-Ic7H2OA9oUpA,739
4
+ apple_search_ads_client-1.0.3.dist-info/licenses/LICENSE,sha256=UtWDM6w7nMyyFqRyOZnsYCr408jfBPWGJGVXAx8pIKM,1065
5
+ apple_search_ads_client-1.0.3.dist-info/METADATA,sha256=3VMCxDYTZ5yyhXInaCo3X3QpyQRB1hQzjxFJzm1rwVY,10199
6
+ apple_search_ads_client-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ apple_search_ads_client-1.0.3.dist-info/top_level.txt,sha256=VhpVXXfA5PVMfMnKWEerJP-NvvjQIUQdV5h-jU2CJyQ,17
8
+ apple_search_ads_client-1.0.3.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- apple_search_ads/__init__.py,sha256=ezclNXkjRbYpsSrx1TV8DrRV1dGH5Pf4nephHtY_DtM,255
2
- apple_search_ads/client.py,sha256=J9Fw_ghyms2P1x5K7LXAjZ02twhTeH9YvfE5Fa9Vh-w,23708
3
- apple_search_ads/exceptions.py,sha256=NNnpFwGZHtfKI_nAp35zz_s02C-zWhONSDDoEsP66Rc,732
4
- apple_search_ads_client-1.0.0.dist-info/licenses/LICENSE,sha256=UtWDM6w7nMyyFqRyOZnsYCr408jfBPWGJGVXAx8pIKM,1065
5
- apple_search_ads_client-1.0.0.dist-info/METADATA,sha256=coZ0bOeYsXu7Ocmy_21dAp4o6NPfCur8Nbi9zDfd8a0,10296
6
- apple_search_ads_client-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- apple_search_ads_client-1.0.0.dist-info/top_level.txt,sha256=VhpVXXfA5PVMfMnKWEerJP-NvvjQIUQdV5h-jU2CJyQ,17
8
- apple_search_ads_client-1.0.0.dist-info/RECORD,,