apple-search-ads-client 1.0.0__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.

@@ -0,0 +1,13 @@
1
+ """
2
+ Apple Search Ads Python Client
3
+
4
+ A Python client library for Apple Search Ads API v5.
5
+ """
6
+
7
+ from .client import AppleSearchAdsClient
8
+
9
+ __version__ = "0.1.0"
10
+ __author__ = "Your Name"
11
+ __email__ = "your.email@example.com"
12
+
13
+ __all__ = ["AppleSearchAdsClient"]
@@ -0,0 +1,646 @@
1
+ """
2
+ Apple Search Ads API Client for Python
3
+
4
+ A Python client for interacting with Apple Search Ads API v5.
5
+ """
6
+
7
+ import jwt
8
+ import time
9
+ import os
10
+ import requests
11
+ from datetime import datetime, timedelta
12
+ from typing import Dict, List, Optional, Any, Union
13
+ import pandas as pd
14
+ from ratelimit import limits, sleep_and_retry
15
+
16
+
17
+ class AppleSearchAdsClient:
18
+ """
19
+ Client for Apple Search Ads API v5.
20
+
21
+ This client provides methods to interact with Apple Search Ads API,
22
+ including campaign management, reporting, and spend tracking.
23
+
24
+ Args:
25
+ client_id: Apple Search Ads client ID
26
+ team_id: Apple Search Ads team ID
27
+ key_id: Apple Search Ads key ID
28
+ private_key_path: Path to the private key .p8 file
29
+ private_key_content: Private key content as string (alternative to file path)
30
+ org_id: Optional organization ID (will be fetched automatically if not provided)
31
+
32
+ Example:
33
+ >>> client = AppleSearchAdsClient(
34
+ ... client_id="your_client_id",
35
+ ... team_id="your_team_id",
36
+ ... key_id="your_key_id",
37
+ ... private_key_path="/path/to/private_key.p8"
38
+ ... )
39
+ >>> campaigns = client.get_campaigns()
40
+ """
41
+
42
+ BASE_URL = "https://api.searchads.apple.com/api/v5"
43
+
44
+ def __init__(
45
+ self,
46
+ client_id: Optional[str] = None,
47
+ team_id: Optional[str] = None,
48
+ key_id: Optional[str] = None,
49
+ private_key_path: Optional[str] = None,
50
+ private_key_content: Optional[str] = None,
51
+ org_id: Optional[str] = None
52
+ ):
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')
58
+ self.private_key_content = private_key_content
59
+
60
+ # Validate required credentials
61
+ if not all([self.client_id, self.team_id, self.key_id]):
62
+ raise ValueError(
63
+ "Missing required credentials. Please provide client_id, team_id, and key_id "
64
+ "either as parameters or environment variables."
65
+ )
66
+
67
+ if not self.private_key_path and not self.private_key_content:
68
+ raise ValueError(
69
+ "Missing private key. Please provide either private_key_path or private_key_content."
70
+ )
71
+
72
+ self.org_id = org_id
73
+ self._token = None
74
+ self._token_expiry = None
75
+
76
+ def _load_private_key(self) -> str:
77
+ """Load private key from file or content."""
78
+ if self.private_key_content:
79
+ return self.private_key_content
80
+
81
+ if not os.path.exists(self.private_key_path):
82
+ raise FileNotFoundError(f"Private key file not found: {self.private_key_path}")
83
+
84
+ with open(self.private_key_path, 'r') as f:
85
+ return f.read()
86
+
87
+ def _generate_client_secret(self) -> str:
88
+ """Generate client secret JWT for Apple Search Ads."""
89
+ # Token expires in 180 days (max allowed by Apple)
90
+ expiry = int(time.time() + 86400 * 180)
91
+
92
+ payload = {
93
+ "sub": self.client_id,
94
+ "aud": "https://appleid.apple.com",
95
+ "iat": int(time.time()),
96
+ "exp": expiry,
97
+ "iss": self.team_id
98
+ }
99
+
100
+ headers = {
101
+ "alg": "ES256",
102
+ "kid": self.key_id
103
+ }
104
+
105
+ private_key = self._load_private_key()
106
+
107
+ return jwt.encode(
108
+ payload,
109
+ private_key,
110
+ algorithm="ES256",
111
+ headers=headers
112
+ )
113
+
114
+ def _get_access_token(self) -> str:
115
+ """Get access token using client credentials flow."""
116
+ if self._token and self._token_expiry and time.time() < self._token_expiry:
117
+ return self._token
118
+
119
+ token_url = "https://appleid.apple.com/auth/oauth2/token"
120
+
121
+ data = {
122
+ "grant_type": "client_credentials",
123
+ "client_id": self.client_id,
124
+ "client_secret": self._generate_client_secret(),
125
+ "scope": "searchadsorg"
126
+ }
127
+
128
+ response = requests.post(token_url, data=data)
129
+ response.raise_for_status()
130
+
131
+ token_data = response.json()
132
+ self._token = token_data["access_token"]
133
+ # Token expires in 1 hour, refresh 5 minutes before
134
+ self._token_expiry = time.time() + 3300
135
+
136
+ return self._token
137
+
138
+ def _get_headers(self, include_org_context: bool = True) -> Dict[str, str]:
139
+ """Get headers for API requests."""
140
+ headers = {
141
+ "Authorization": f"Bearer {self._get_access_token()}",
142
+ "Content-Type": "application/json"
143
+ }
144
+
145
+ # Add organization context if we have it (not needed for ACLs endpoint)
146
+ if include_org_context and self.org_id:
147
+ headers["X-AP-Context"] = f"orgId={self.org_id}"
148
+
149
+ return headers
150
+
151
+ def _get_org_id(self) -> str:
152
+ """Get the organization ID."""
153
+ if self.org_id:
154
+ return self.org_id
155
+
156
+ response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
157
+
158
+ if response and "data" in response and len(response["data"]) > 0:
159
+ self.org_id = str(response["data"][0]["orgId"])
160
+ return self.org_id
161
+
162
+ raise ValueError("No organization found for this account")
163
+
164
+ @sleep_and_retry
165
+ @limits(calls=10, period=1) # Apple Search Ads rate limit
166
+ def _make_request(
167
+ self,
168
+ url: str,
169
+ method: str = "GET",
170
+ json_data: Optional[Dict] = None,
171
+ params: Optional[Dict] = None,
172
+ include_org_context: bool = True
173
+ ) -> Dict:
174
+ """Make a rate-limited request to the API."""
175
+ response = requests.request(
176
+ method=method,
177
+ url=url,
178
+ headers=self._get_headers(include_org_context=include_org_context),
179
+ json=json_data,
180
+ params=params
181
+ )
182
+ response.raise_for_status()
183
+ return response.json()
184
+
185
+ def get_all_organizations(self) -> List[Dict]:
186
+ """
187
+ Get all organizations the user has access to.
188
+
189
+ Returns:
190
+ List of organization dictionaries containing orgId, orgName, etc.
191
+ """
192
+ response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
193
+
194
+ if response and "data" in response:
195
+ return response["data"]
196
+
197
+ return []
198
+
199
+
200
+ def get_adgroups(self, campaign_id: str) -> List[Dict]:
201
+ """
202
+ Get all ad groups for a specific campaign.
203
+
204
+ Args:
205
+ campaign_id: The campaign ID to get ad groups for
206
+
207
+ Returns:
208
+ List of ad group dictionaries
209
+ """
210
+ # Ensure we have org_id for the context header
211
+ if not self.org_id:
212
+ self._get_org_id()
213
+
214
+ url = f"{self.BASE_URL}/campaigns/{campaign_id}/adgroups"
215
+
216
+ params = {
217
+ "limit": 1000
218
+ }
219
+
220
+ response = self._make_request(url, params=params)
221
+ return response.get("data", [])
222
+
223
+ def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict]:
224
+ """
225
+ Get all campaigns for a specific organization or the default one.
226
+
227
+ Args:
228
+ org_id: Optional organization ID. If not provided, uses the default org.
229
+
230
+ Returns:
231
+ List of campaign dictionaries.
232
+ """
233
+ # Use provided org_id or get the default one
234
+ original_org_id = None
235
+ if org_id:
236
+ original_org_id = self.org_id
237
+ self.org_id = str(org_id)
238
+ elif not self.org_id:
239
+ self._get_org_id()
240
+
241
+ url = f"{self.BASE_URL}/campaigns"
242
+
243
+ params = {
244
+ "limit": 1000
245
+ }
246
+
247
+ try:
248
+ response = self._make_request(url, params=params)
249
+ campaigns = response.get("data", [])
250
+
251
+ # Add org_id to each campaign for tracking
252
+ for campaign in campaigns:
253
+ campaign["fetched_org_id"] = self.org_id
254
+
255
+ return campaigns
256
+ finally:
257
+ # Restore original org_id if we changed it
258
+ if original_org_id is not None:
259
+ self.org_id = original_org_id
260
+
261
+ def get_all_campaigns(self) -> List[Dict]:
262
+ """
263
+ Get campaigns from all organizations.
264
+
265
+ Returns:
266
+ List of all campaigns across all organizations.
267
+ """
268
+ all_campaigns = []
269
+ organizations = self.get_all_organizations()
270
+
271
+ for org in organizations:
272
+ org_id = str(org["orgId"])
273
+ org_name = org.get("orgName", "Unknown")
274
+
275
+ try:
276
+ campaigns = self.get_campaigns(org_id=org_id)
277
+
278
+ # Add organization info to each campaign
279
+ for campaign in campaigns:
280
+ campaign["org_name"] = org_name
281
+ campaign["parent_org_id"] = org.get("parentOrgId")
282
+
283
+ all_campaigns.extend(campaigns)
284
+ except Exception as e:
285
+ print(f"Error fetching campaigns from {org_name}: {e}")
286
+
287
+ return all_campaigns
288
+
289
+ def get_campaign_report(
290
+ self,
291
+ start_date: Union[datetime, str],
292
+ end_date: Union[datetime, str],
293
+ granularity: str = "DAILY"
294
+ ) -> pd.DataFrame:
295
+ """
296
+ Get campaign performance report.
297
+
298
+ Args:
299
+ start_date: Start date for the report (datetime or YYYY-MM-DD string)
300
+ end_date: End date for the report (datetime or YYYY-MM-DD string)
301
+ granularity: DAILY, WEEKLY, or MONTHLY
302
+
303
+ Returns:
304
+ DataFrame with campaign performance metrics.
305
+ """
306
+ # Ensure we have org_id for the context header
307
+ if not self.org_id:
308
+ self._get_org_id()
309
+
310
+ # Convert string dates to datetime if needed
311
+ if isinstance(start_date, str):
312
+ start_date = datetime.strptime(start_date, "%Y-%m-%d")
313
+ if isinstance(end_date, str):
314
+ end_date = datetime.strptime(end_date, "%Y-%m-%d")
315
+
316
+ url = f"{self.BASE_URL}/reports/campaigns"
317
+
318
+ # Apple Search Ads API uses date-only format
319
+ request_data = {
320
+ "startTime": start_date.strftime("%Y-%m-%d"),
321
+ "endTime": end_date.strftime("%Y-%m-%d"),
322
+ "granularity": granularity,
323
+ "selector": {
324
+ "orderBy": [{"field": "localSpend", "sortOrder": "DESCENDING"}],
325
+ "pagination": {"limit": 1000}
326
+ },
327
+ "returnRowTotals": True,
328
+ "returnRecordsWithNoMetrics": False
329
+ }
330
+
331
+ response = self._make_request(
332
+ url,
333
+ method="POST",
334
+ json_data=request_data
335
+ )
336
+
337
+ # Handle different response structures
338
+ rows = []
339
+ if response and "data" in response:
340
+ if "reportingDataResponse" in response["data"] and "row" in response["data"]["reportingDataResponse"]:
341
+ # New response structure
342
+ rows = response["data"]["reportingDataResponse"]["row"]
343
+ elif "rows" in response["data"]:
344
+ # Old response structure
345
+ rows = response["data"]["rows"]
346
+
347
+ if rows:
348
+ # Extract data into a flat structure
349
+ data = []
350
+ for row in rows:
351
+ metadata = row.get("metadata", {})
352
+
353
+ # For the new structure, we need to process granularity data
354
+ if "granularity" in row:
355
+ # New structure: iterate through each day in granularity
356
+ for day_data in row["granularity"]:
357
+ data.append({
358
+ "date": day_data.get("date"),
359
+ "campaign_id": metadata.get("campaignId"),
360
+ "campaign_name": metadata.get("campaignName"),
361
+ "campaign_status": metadata.get("campaignStatus"),
362
+ "app_name": metadata.get("app", {}).get("appName") if "app" in metadata else metadata.get("appName"),
363
+ "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
+
396
+ return pd.DataFrame(data)
397
+
398
+ return pd.DataFrame()
399
+
400
+ def get_daily_spend(
401
+ self,
402
+ days: int = 30,
403
+ fetch_all_orgs: bool = True
404
+ ) -> pd.DataFrame:
405
+ """
406
+ Get daily spend across all campaigns.
407
+
408
+ Args:
409
+ days: Number of days to fetch
410
+ fetch_all_orgs: If True, fetches from all organizations
411
+
412
+ Returns:
413
+ DataFrame with daily spend metrics.
414
+ """
415
+ end_date = datetime.now()
416
+ start_date = end_date - timedelta(days=days)
417
+
418
+ return self.get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs)
419
+
420
+ 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
425
+ ) -> pd.DataFrame:
426
+ """
427
+ Get daily spend across all campaigns for a specific date range.
428
+
429
+ Args:
430
+ start_date: Start date for the report
431
+ end_date: End date for the report
432
+ fetch_all_orgs: If True, fetches from all organizations
433
+
434
+ Returns:
435
+ DataFrame with daily spend metrics.
436
+ """
437
+ all_campaign_data = []
438
+
439
+ if fetch_all_orgs:
440
+ organizations = self.get_all_organizations()
441
+
442
+ for org in organizations:
443
+ org_id = str(org["orgId"])
444
+
445
+ # Set org context
446
+ current_org_id = self.org_id
447
+ self.org_id = org_id
448
+
449
+ try:
450
+ # Get campaign report for this org
451
+ org_campaign_df = self.get_campaign_report(start_date, end_date)
452
+ if not org_campaign_df.empty:
453
+ all_campaign_data.append(org_campaign_df)
454
+ except Exception:
455
+ pass
456
+ finally:
457
+ # Restore original org_id
458
+ self.org_id = current_org_id
459
+ else:
460
+ campaign_df = self.get_campaign_report(start_date, end_date)
461
+ if not campaign_df.empty:
462
+ all_campaign_data.append(campaign_df)
463
+
464
+ if not all_campaign_data:
465
+ return pd.DataFrame()
466
+
467
+ # Combine all campaign data
468
+ campaign_df = pd.concat(all_campaign_data, ignore_index=True)
469
+
470
+ # 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
+
478
+ # 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
482
+ )
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
487
+ )
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
492
+ )
493
+
494
+
495
+ return daily_df
496
+
497
+ def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict]:
498
+ """
499
+ Get all campaigns with their app details including adamId.
500
+
501
+ Args:
502
+ fetch_all_orgs: If True, fetches from all organizations
503
+
504
+ Returns:
505
+ List of campaign dictionaries with app details.
506
+ """
507
+ if fetch_all_orgs:
508
+ campaigns = self.get_all_campaigns()
509
+ else:
510
+ if not self.org_id:
511
+ self._get_org_id()
512
+ campaigns = self.get_campaigns()
513
+
514
+ return campaigns
515
+
516
+ 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
521
+ ) -> pd.DataFrame:
522
+ """
523
+ Get daily advertising spend grouped by app (adamId).
524
+
525
+ Args:
526
+ start_date: Start date for the report
527
+ end_date: End date for the report
528
+ fetch_all_orgs: If True, fetches from all organizations
529
+
530
+ Returns:
531
+ DataFrame with columns:
532
+ - date: The date
533
+ - app_id: Apple App Store ID (adamId)
534
+ - spend: Total spend in USD
535
+ - impressions: Total impressions
536
+ - taps: Total taps on ads
537
+ - installs: Total conversions/installs
538
+ - campaigns: Number of active campaigns
539
+ """
540
+ # First, get campaign-to-app mapping
541
+ campaigns = self.get_campaigns_with_details(fetch_all_orgs=fetch_all_orgs)
542
+ campaign_to_app = {str(c["id"]): str(c.get("adamId")) for c in campaigns if c.get("adamId")}
543
+
544
+ # Get campaign reports from all organizations
545
+ all_campaign_data = []
546
+
547
+ if fetch_all_orgs:
548
+ organizations = self.get_all_organizations()
549
+
550
+ for org in organizations:
551
+ org_id = str(org["orgId"])
552
+ org_name = org.get("orgName", "Unknown")
553
+
554
+ # Set org context
555
+ current_org_id = self.org_id
556
+ self.org_id = org_id
557
+
558
+ try:
559
+ # Get campaign report for this org
560
+ org_campaign_df = self.get_campaign_report(start_date, end_date)
561
+ if not org_campaign_df.empty:
562
+ # Add org info to the dataframe
563
+ org_campaign_df["org_id"] = org_id
564
+ org_campaign_df["org_name"] = org_name
565
+ all_campaign_data.append(org_campaign_df)
566
+ except Exception:
567
+ pass
568
+ finally:
569
+ # Restore original org_id
570
+ self.org_id = current_org_id
571
+ else:
572
+ # Just get from default org
573
+ campaign_df = self.get_campaign_report(start_date, end_date)
574
+ if not campaign_df.empty:
575
+ all_campaign_data.append(campaign_df)
576
+
577
+ if not all_campaign_data:
578
+ return pd.DataFrame()
579
+
580
+ # Combine all campaign data
581
+ campaign_df = pd.concat(all_campaign_data, ignore_index=True)
582
+
583
+ # Convert campaign_id to string for mapping
584
+ campaign_df["campaign_id"] = campaign_df["campaign_id"].astype(str)
585
+
586
+ # Map campaigns to apps
587
+ campaign_df["app_id"] = campaign_df["campaign_id"].map(campaign_to_app)
588
+
589
+ # Filter out campaigns without app mapping
590
+ app_df = campaign_df[campaign_df["app_id"].notna()].copy()
591
+
592
+ if app_df.empty:
593
+ return pd.DataFrame()
594
+
595
+ # 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
+
604
+ # Rename columns to match standard format
605
+ aggregated.rename(columns={
606
+ 'campaign_id': 'campaigns'
607
+ }, inplace=True)
608
+
609
+ # Add derived metrics
610
+ aggregated['cpi'] = aggregated.apply(
611
+ lambda x: x['spend'] / x['installs'] if x['installs'] > 0 else 0,
612
+ axis=1
613
+ ).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
618
+ ).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
623
+ ).round(2)
624
+
625
+ # Sort by date and app
626
+ aggregated.sort_values(['date', 'app_id'], inplace=True)
627
+
628
+ # Filter to ensure we only return data within the requested date range
629
+ if isinstance(start_date, str):
630
+ start_date = datetime.strptime(start_date, "%Y-%m-%d")
631
+ if isinstance(end_date, str):
632
+ 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
+
638
+ aggregated = aggregated[
639
+ (aggregated['date_dt'].dt.date >= start_date_only) &
640
+ (aggregated['date_dt'].dt.date <= end_date_only)
641
+ ].copy()
642
+
643
+ # Drop the temporary datetime column
644
+ aggregated.drop('date_dt', axis=1, inplace=True)
645
+
646
+ return aggregated
@@ -0,0 +1,33 @@
1
+ """
2
+ Custom exceptions for Apple Search Ads Python Client.
3
+ """
4
+
5
+
6
+ class AppleSearchAdsError(Exception):
7
+ """Base exception for Apple Search Ads API errors."""
8
+ pass
9
+
10
+
11
+ class AuthenticationError(AppleSearchAdsError):
12
+ """Raised when authentication fails."""
13
+ pass
14
+
15
+
16
+ class RateLimitError(AppleSearchAdsError):
17
+ """Raised when API rate limit is exceeded."""
18
+ pass
19
+
20
+
21
+ class InvalidRequestError(AppleSearchAdsError):
22
+ """Raised when the API request is invalid."""
23
+ pass
24
+
25
+
26
+ class OrganizationNotFoundError(AppleSearchAdsError):
27
+ """Raised when no organization is found for the account."""
28
+ pass
29
+
30
+
31
+ class ConfigurationError(AppleSearchAdsError):
32
+ """Raised when the client is not configured properly."""
33
+ pass
@@ -0,0 +1,367 @@
1
+ Metadata-Version: 2.4
2
+ Name: apple-search-ads-client
3
+ Version: 1.0.0
4
+ Summary: A Python client for Apple Search Ads API v5
5
+ Home-page: https://github.com/bickster/apple-search-ads-python
6
+ Author: Bickster LLC
7
+ Author-email: Bickster LLC <support@bickster.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/bickster/apple-search-ads-python
10
+ Project-URL: Documentation, https://apple-search-ads-python.readthedocs.io/
11
+ Project-URL: Repository, https://github.com/bickster/apple-search-ads-python
12
+ Project-URL: Issues, https://github.com/bickster/apple-search-ads-python/issues
13
+ Keywords: apple,search,ads,api,marketing,advertising,ios,app,store
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Operating System :: OS Independent
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Software Development :: Libraries
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: PyJWT>=2.8.0
31
+ Requires-Dist: cryptography>=41.0.0
32
+ Requires-Dist: requests>=2.31.0
33
+ Requires-Dist: pandas>=2.0.0
34
+ Requires-Dist: ratelimit>=2.2.1
35
+ Requires-Dist: python-dateutil>=2.8.0
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
38
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
39
+ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
40
+ Requires-Dist: black>=23.0.0; extra == "dev"
41
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
42
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
43
+ Requires-Dist: sphinx>=6.0.0; extra == "dev"
44
+ Requires-Dist: sphinx-rtd-theme>=1.0.0; extra == "dev"
45
+ Dynamic: author
46
+ Dynamic: home-page
47
+ Dynamic: license-file
48
+ Dynamic: requires-python
49
+
50
+ # Apple Search Ads Python Client
51
+
52
+ A Python client library for Apple Search Ads API v5, providing a simple and intuitive interface for managing and reporting on Apple Search Ads campaigns.
53
+
54
+ ## Features
55
+
56
+ - 🔐 OAuth2 authentication with JWT
57
+ - 📊 Campaign performance reporting
58
+ - 🏢 Multi-organization support
59
+ - 💰 Spend tracking by app
60
+ - ⚡ Built-in rate limiting
61
+ - 🐼 Pandas DataFrames for easy data manipulation
62
+ - 🔄 Automatic token refresh
63
+ - 🎯 Type hints for better IDE support
64
+ - ✅ 100% test coverage
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pip install apple-search-ads-client
70
+ ```
71
+
72
+ ## Quick Start
73
+
74
+ ```python
75
+ from apple_search_ads import AppleSearchAdsClient
76
+
77
+ # Initialize the client
78
+ client = AppleSearchAdsClient(
79
+ client_id="your_client_id",
80
+ team_id="your_team_id",
81
+ key_id="your_key_id",
82
+ private_key_path="/path/to/private_key.p8"
83
+ )
84
+
85
+ # Get all campaigns
86
+ campaigns = client.get_campaigns()
87
+
88
+ # Get daily spend for the last 30 days
89
+ spend_df = client.get_daily_spend(days=30)
90
+ print(spend_df)
91
+ ```
92
+
93
+ ## Authentication
94
+
95
+ ### Prerequisites
96
+
97
+ 1. An Apple Search Ads account with API access
98
+ 2. API credentials from the Apple Search Ads UI:
99
+ - Client ID
100
+ - Team ID
101
+ - Key ID
102
+ - Private key file (.p8)
103
+
104
+ ### Setting up credentials
105
+
106
+ You can provide credentials in three ways:
107
+
108
+ #### 1. Direct parameters (recommended)
109
+
110
+ ```python
111
+ client = AppleSearchAdsClient(
112
+ client_id="your_client_id",
113
+ team_id="your_team_id",
114
+ key_id="your_key_id",
115
+ private_key_path="/path/to/private_key.p8"
116
+ )
117
+ ```
118
+
119
+ #### 2. Environment variables
120
+
121
+ ```bash
122
+ export APPLE_SEARCH_ADS_CLIENT_ID="your_client_id"
123
+ export APPLE_SEARCH_ADS_TEAM_ID="your_team_id"
124
+ export APPLE_SEARCH_ADS_KEY_ID="your_key_id"
125
+ export APPLE_SEARCH_ADS_PRIVATE_KEY_PATH="/path/to/private_key.p8"
126
+ ```
127
+
128
+ ```python
129
+ client = AppleSearchAdsClient() # Will use environment variables
130
+ ```
131
+
132
+ #### 3. Private key content
133
+
134
+ ```python
135
+ # Useful for environments where file access is limited
136
+ with open("private_key.p8", "r") as f:
137
+ private_key_content = f.read()
138
+
139
+ client = AppleSearchAdsClient(
140
+ client_id="your_client_id",
141
+ team_id="your_team_id",
142
+ key_id="your_key_id",
143
+ private_key_content=private_key_content
144
+ )
145
+ ```
146
+
147
+ ## Usage Examples
148
+
149
+ ### Get all organizations
150
+
151
+ ```python
152
+ # List all organizations you have access to
153
+ orgs = client.get_all_organizations()
154
+ for org in orgs:
155
+ print(f"{org['orgName']} - {org['orgId']}")
156
+ ```
157
+
158
+ ### Get campaign performance report
159
+
160
+ ```python
161
+ from datetime import datetime, timedelta
162
+
163
+ # Get campaign performance for the last 7 days
164
+ end_date = datetime.now()
165
+ start_date = end_date - timedelta(days=7)
166
+
167
+ report_df = client.get_campaign_report(
168
+ start_date=start_date,
169
+ end_date=end_date,
170
+ granularity="DAILY" # Options: DAILY, WEEKLY, MONTHLY
171
+ )
172
+
173
+ # Display key metrics
174
+ print(report_df[['date', 'campaign_name', 'spend', 'installs', 'taps']])
175
+ ```
176
+
177
+ ### Track spend by app
178
+
179
+ ```python
180
+ # Get daily spend grouped by app
181
+ app_spend_df = client.get_daily_spend_by_app(
182
+ start_date="2024-01-01",
183
+ end_date="2024-01-31",
184
+ fetch_all_orgs=True # Fetch from all organizations
185
+ )
186
+
187
+ # Group by app and sum
188
+ app_totals = app_spend_df.groupby('app_id').agg({
189
+ 'spend': 'sum',
190
+ 'installs': 'sum',
191
+ 'impressions': 'sum'
192
+ }).round(2)
193
+
194
+ print(app_totals)
195
+ ```
196
+
197
+ ### Get campaigns from all organizations
198
+
199
+ ```python
200
+ # Fetch campaigns across all organizations
201
+ all_campaigns = client.get_all_campaigns()
202
+
203
+ # Filter active campaigns
204
+ active_campaigns = [c for c in all_campaigns if c['status'] == 'ENABLED']
205
+
206
+ print(f"Found {len(active_campaigns)} active campaigns across all orgs")
207
+ ```
208
+
209
+ ### Working with specific organization
210
+
211
+ ```python
212
+ # Get campaigns for a specific org
213
+ org_id = "123456"
214
+ campaigns = client.get_campaigns(org_id=org_id)
215
+
216
+ # The client will use this org for subsequent requests
217
+ ```
218
+
219
+ ### Working with ad groups
220
+
221
+ ```python
222
+ # Get ad groups for a campaign
223
+ campaign_id = "1234567890"
224
+ adgroups = client.get_adgroups(campaign_id)
225
+
226
+ for adgroup in adgroups:
227
+ print(f"Ad Group: {adgroup['name']} (Status: {adgroup['status']})")
228
+ ```
229
+
230
+ ## API Reference
231
+
232
+ ### Client initialization
233
+
234
+ ```python
235
+ AppleSearchAdsClient(
236
+ client_id: Optional[str] = None,
237
+ team_id: Optional[str] = None,
238
+ key_id: Optional[str] = None,
239
+ private_key_path: Optional[str] = None,
240
+ private_key_content: Optional[str] = None,
241
+ org_id: Optional[str] = None
242
+ )
243
+ ```
244
+
245
+ ### Methods
246
+
247
+ #### Organizations
248
+
249
+ - `get_all_organizations()` - Get all organizations
250
+ - `get_campaigns(org_id: Optional[str] = None)` - Get campaigns for an organization
251
+ - `get_all_campaigns()` - Get campaigns from all organizations
252
+
253
+ #### Reporting
254
+
255
+ - `get_campaign_report(start_date, end_date, granularity="DAILY")` - Get campaign performance report
256
+ - `get_daily_spend(days=30, fetch_all_orgs=True)` - Get daily spend for the last N days
257
+ - `get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs=True)` - Get daily spend for date range
258
+ - `get_daily_spend_by_app(start_date, end_date, fetch_all_orgs=True)` - Get spend grouped by app
259
+
260
+ #### Campaign Management
261
+
262
+ - `get_campaigns_with_details(fetch_all_orgs=True)` - Get campaigns with app details
263
+ - `get_adgroups(campaign_id)` - Get ad groups for a specific campaign
264
+
265
+ ## DataFrame Output
266
+
267
+ All reporting methods return pandas DataFrames for easy data manipulation:
268
+
269
+ ```python
270
+ # Example: Calculate weekly totals
271
+ daily_spend = client.get_daily_spend(days=30)
272
+ daily_spend['week'] = pd.to_datetime(daily_spend['date']).dt.isocalendar().week
273
+ weekly_totals = daily_spend.groupby('week')['spend'].sum()
274
+ ```
275
+
276
+ ## Rate Limiting
277
+
278
+ The client includes built-in rate limiting to respect Apple's API limits (10 requests per second). You don't need to implement any additional rate limiting.
279
+
280
+ ## Error Handling
281
+
282
+ ```python
283
+ from apple_search_ads.exceptions import (
284
+ AuthenticationError,
285
+ RateLimitError,
286
+ OrganizationNotFoundError
287
+ )
288
+
289
+ try:
290
+ campaigns = client.get_campaigns()
291
+ except AuthenticationError as e:
292
+ print(f"Authentication failed: {e}")
293
+ except RateLimitError as e:
294
+ print(f"Rate limit exceeded: {e}")
295
+ except Exception as e:
296
+ print(f"An error occurred: {e}")
297
+ ```
298
+
299
+ ## Best Practices
300
+
301
+ 1. **Reuse client instances**: Create one client and reuse it for multiple requests
302
+ 2. **Use date ranges wisely**: Large date ranges may result in slower responses
303
+ 3. **Cache organization IDs**: If working with specific orgs frequently, cache their IDs
304
+ 4. **Monitor rate limits**: Although built-in rate limiting is included, be mindful of your usage
305
+ 5. **Use DataFrame operations**: Leverage pandas for data aggregation and analysis
306
+
307
+ ## Requirements
308
+
309
+ - Python 3.8 or higher
310
+ - See `requirements.txt` for package dependencies
311
+
312
+ ## Testing
313
+
314
+ This project maintains **100% test coverage**. The test suite includes:
315
+
316
+ - Unit tests with mocked API responses
317
+ - Exception handling tests
318
+ - Edge case coverage
319
+ - Legacy API format compatibility tests
320
+ - Comprehensive integration tests
321
+
322
+ ### Running Tests
323
+
324
+ ```bash
325
+ # Run all tests with coverage report
326
+ pytest tests -v --cov=apple_search_ads --cov-report=term-missing
327
+
328
+ # Run tests in parallel for faster execution
329
+ pytest tests -n auto
330
+
331
+ # Generate HTML coverage report
332
+ pytest tests --cov=apple_search_ads --cov-report=html
333
+
334
+ # Run integration tests (requires credentials)
335
+ pytest tests/test_integration.py -v
336
+ ```
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).
340
+
341
+ ## Contributing
342
+
343
+ Contributions are welcome! Please feel free to submit a Pull Request.
344
+
345
+ 1. Fork the repository
346
+ 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
347
+ 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
348
+ 4. Push to the branch (`git push origin feature/AmazingFeature`)
349
+ 5. Open a Pull Request
350
+
351
+ ## License
352
+
353
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
354
+
355
+ ## Support
356
+
357
+ - 🐛 Issues: [GitHub Issues](https://github.com/bickster/apple-search-ads-python/issues)
358
+ - 📖 Documentation: [Read the Docs](https://apple-search-ads-python.readthedocs.io/)
359
+
360
+ ## Changelog
361
+
362
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
363
+
364
+ ## Acknowledgments
365
+
366
+ - Apple for providing the Search Ads API
367
+ - The Python community for excellent libraries used in this project
@@ -0,0 +1,8 @@
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ apple_search_ads