This commit is contained in:
2025-11-04 10:43:13 +00:00
10 changed files with 1304 additions and 58 deletions

View File

@@ -0,0 +1,193 @@
# Meta API Rate Limiter Enhancements
## Summary
Enhanced the rate limiter in [rate_limiter.py](src/meta_api_grabber/rate_limiter.py) to monitor **all** Meta API rate limit headers as documented in the [official Meta documentation](https://developers.facebook.com/docs/graph-api/overview/rate-limiting).
## New Headers Monitored
### 1. **X-App-Usage** (Platform Rate Limits)
Tracks application-level rate limits across all users.
**Fields:**
- `call_count`: Percentage of calls made (0-100)
- `total_cputime`: Percentage of CPU time used (0-100)
- `total_time`: Percentage of total time used (0-100)
**Example:**
```json
{
"call_count": 28,
"total_time": 25,
"total_cputime": 25
}
```
### 2. **X-Ad-Account-Usage** (Ad Account Specific)
Tracks rate limits for specific ad accounts.
**Fields:**
- `acc_id_util_pct`: Percentage of ad account usage (0-100)
- `reset_time_duration`: Time in seconds until rate limit resets
- `ads_api_access_tier`: Access tier (e.g., "standard_access", "development_access")
**Example:**
```json
{
"acc_id_util_pct": 9.67,
"reset_time_duration": 100,
"ads_api_access_tier": "standard_access"
}
```
### 3. **X-Business-Use-Case-Usage** (Business Use Case Limits)
Tracks rate limits per business use case (ads_insights, ads_management, etc.).
**Fields:**
- `business_id`: Business object ID
- `type`: Type of BUC (ads_insights, ads_management, custom_audience, etc.)
- `call_count`: Percentage of calls made (0-100)
- `total_cputime`: Percentage of CPU time (0-100)
- `total_time`: Percentage of total time (0-100)
- `estimated_time_to_regain_access`: Time in minutes until access is restored
- `ads_api_access_tier`: Access tier
**Example:**
```json
{
"66782684": [{
"type": "ads_management",
"call_count": 95,
"total_cputime": 20,
"total_time": 20,
"estimated_time_to_regain_access": 0,
"ads_api_access_tier": "development_access"
}]
}
```
### 4. **x-fb-ads-insights-throttle** (Legacy)
Original header still supported for backward compatibility.
**Fields:**
- `app_id_util_pct`: App usage percentage
- `acc_id_util_pct`: Account usage percentage
## Key Enhancements
### 1. Intelligent Throttling
The rate limiter now uses `estimated_time_to_regain_access` and `reset_time_duration` to calculate optimal delays:
```python
# If we have estimated_time_to_regain_access from BUC header
if self.estimated_time_to_regain_access > 0:
delay = self.estimated_time_to_regain_access * 60 # Convert minutes to seconds
# If we have reset_time_duration from Ad Account header
elif self.reset_time_duration > 0:
delay = self.reset_time_duration * 0.5 # Use fraction as safety margin
```
### 2. Comprehensive Error Code Detection
Expanded error code detection to include all Meta rate limit error codes:
- **4**: App rate limit
- **17**: User rate limit
- **32**: Pages rate limit
- **613**: Custom rate limit
- **80000-80014**: Business Use Case rate limits (Ads Insights, Ads Management, Custom Audience, Instagram, LeadGen, Messenger, Pages, WhatsApp, Catalog)
### 3. Debug Logging
All headers are now logged in DEBUG mode with detailed parsing information:
```python
logger.debug(f"X-App-Usage header: {header_value}")
logger.debug(f"Parsed X-App-Usage: {result}")
```
### 4. Enhanced Statistics
The `get_stats()` and `print_stats()` methods now display comprehensive metrics from all headers:
```
======================================================================
RATE LIMITER STATISTICS
======================================================================
Total Requests: 0
Throttled Requests: 0
Rate Limit Errors: 0
X-App-Usage (Platform Rate Limits):
Call Count: 95.0%
Total CPU Time: 90.0%
Total Time: 88.0%
X-Ad-Account-Usage:
Account Usage: 97.5%
Reset Time Duration: 300s
API Access Tier: standard_access
X-Business-Use-Case-Usage:
Type: ads_insights
Call Count: 98.0%
Total CPU Time: 95.0%
Total Time: 92.0%
Est. Time to Regain: 15 min
Legacy (x-fb-ads-insights-throttle):
App Usage: 93.0%
Account Usage: 96.0%
Max Usage Across All Metrics: 98.0%
Currently Throttled: True
======================================================================
```
## Usage
### Enable Debug Logging
To see all header parsing in debug mode:
```python
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
```
### Access New Metrics
All metrics are available through the `get_stats()` method:
```python
stats = limiter.get_stats()
print(f"App call count: {stats['app_call_count']}%")
print(f"Ad account usage: {stats['ad_account_usage_pct']}%")
print(f"Reset in: {stats['reset_time_duration']}s")
print(f"Regain access in: {stats['estimated_time_to_regain_access']} min")
print(f"API tier: {stats['ads_api_access_tier']}")
# Business use case details
for buc in stats['buc_usage']:
print(f"BUC {buc['type']}: {buc['call_count']}%")
```
## Testing
Run the test script to see the rate limiter in action:
```bash
uv run python test_rate_limiter.py
```
This will demonstrate:
- Parsing all four header types
- Intelligent throttling based on usage
- Comprehensive statistics display
- Debug logging output
## References
- [Meta Graph API Rate Limiting](https://developers.facebook.com/docs/graph-api/overview/rate-limiting)
- [Meta Marketing API Best Practices](https://developers.facebook.com/docs/marketing-api/insights/best-practices/)

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "analyticsdashboard-476613",
"private_key_id": "eb23490ceed829c7b0e14bdaac3c5accf8d008c9",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDAOdmUXs2KDY/r\ndRhbFjRdyZTTGnMVscxMvWf5N/quf0f+bOBrN5jz9fJKqs+kxS5556xgZ2r+2FGQ\nCeKCPk8I2kocmqU12rb5ncopef3+wMRTuHJNF0wit/rsPyfar7T5rC4isB/xP3Ss\nZcZ3jA/IfFnmfH4jdHu4okoKA7SqxvjqPpOjTmKhwV40YdG+mCjr7LJC7lxahfh9\n7w1YCC07w253P78goRgM5dFvcNY0THAsq4a4blnKQQ6lKUkxZ4DvIDIOyml4YZ23\nqevUSEa8KHiAkTOD6PzLP8rrJi9ovQpIGldr6HtOF+whGuh667Zezn2lRGOLj9zO\nNRMitaQxAgMBAAECggEABHNSbkpOR/FNxmMiT+Q7t3rV+e1ALN2+Sn3izQpWwM4s\nRIpmHRVfHTGHN+NXMLa//2KLGHr3JzSq7sLL0714PE7m2F1cOzBNJt+tsTgkgU7F\nPOBQWn3nmAYvxmMlRmg7BcIkGfl/Q9P28jwzqXY8sfpEZT8MoerYfSCExlE/pZSG\nIaqdSJXssWMu1ZoC4GWj76lzWafC+wjXwAgHlqpi3DnThAg0BYMVOZkPe4Xvmb85\n8is0hKQuc49UXtJ8V9a6zlAM+cKRzNrnRpEMdYagRSDPwwF+J+qh6UqoLnGND7UL\nWuWuss5lJZwIWjIZrVagqJhzBLvBbS6C0UHW7LeGgQKBgQDnCJ3HbYiqIWugDcSW\nMdPGx5Xk9mBvyjPDotBO/C5e8FBzxSp6Qegy9X6RYjIfuKCnVfCCsDoQJzy94RvW\nRn1q0WD1h2ov6LOXSNncOCa3k9jpRMlcnqj3NCkG52R6DPjDf3I/3N49dYVp6UcX\nENrHXBMLJBH4sYLnBIC5TXdE5QKBgQDU/6V5VhMdZc+hrIua+Y8DNznzBH5LeS4x\njx9B9AmwYoD4W4W87p7JwcJma7ZP9OUzO4qlk48ZFwTG9GLZGvyJV8DuY3WrfHCX\nDnoRMzsgS6vdxiRTSIwy0bTi33iuZcJo/KvfPL8dOgEUvQmanqeKz7SJ7O7KhmCk\n18QRMNZZXQKBgGcU4BkQFS8blEKogfMlrkD94jJzf1nBlVEPvvPO7v2rKapN6YL9\nDxZVlLBXaNfgb8XZwWL+MBnu99ocq2fysZjMbP9/+PABWsgAWDw6zYORMvH5oAJ0\nRB1wJ3IOIjWWvhO0NIysBnjTi8BStkZjXcofmduZr28P/MEIsEp9dt7FAoGAbruF\nNGJqR5NBcWS5o1TwY5SXfN6uJeCXAk7MykXrr5ZWREeYbJOFW5Bu1z5SJplDevIO\nb2waLcoIwsIUjZf5CBHmDEkKyJ9GDVIKZdzDdVPBwucaxW1m7ZiWOIhDPi9K9be+\nRq1XEgOwwi5QyuCGa6T1z+qsbf+USL6fgOxp00UCgYBJEJ1t6pyG10KfvmARMg01\n1r+D7EjwPfqcG8svtFX756EMqbYNm7YYYQJ1lG4CgHHI5KVb92DQ8kpxFvCARkra\nJMfKG4PzqkXK1Oqj4+RP6cGw1i4Z6wBNJtkvVRlONz03QVfxRL4UWNGjjMMw0jvh\nTE/wKaiR3JZtP3I0CHtIOg==\n-----END PRIVATE KEY-----\n",
"client_email": "googleanalyticsdienstkonto@analyticsdashboard-476613.iam.gserviceaccount.com",
"client_id": "109465128544817306545",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/googleanalyticsdienstkonto%40analyticsdashboard-476613.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

31
google-ads.yaml Normal file
View File

@@ -0,0 +1,31 @@
# Google Ads API Configuration
# For more information, visit: https://developers.google.com/google-ads/api/docs/client-libs/python/configuration
# Required: Your Google Ads developer token
developer_token: WW_IIk0Al4U-O-XtasOgew
# Use service account authentication with JSON key file
json_key_file_path: analyticsdashboard-476613-eb23490ceed8.json
# Required: Your Google Ads login customer ID (without dashes)
# This is typically your manager account ID
login_customer_id: 6226630160
use_proto_plus: False
# Optional: Logging configuration
logging:
version: 1
disable_existing_loggers: False
formatters:
default_fmt:
format: '[%(asctime)s - %(levelname)s] %(message).5000s'
datefmt: '%Y-%m-%d %H:%M:%S'
handlers:
default_handler:
class: logging.StreamHandler
formatter: default_fmt
loggers:
'':
handlers: [default_handler]
level: INFO

0
google_ads_key.json Normal file
View File

View File

@@ -9,6 +9,7 @@ dependencies = [
"alembic>=1.17.0",
"asyncpg>=0.30.0",
"facebook-business>=23.0.3",
"google-ads>=28.3.0",
"python-dotenv>=1.1.1",
"requests-oauthlib>=2.0.0",
"sqlalchemy[asyncio]>=2.0.44",
@@ -19,7 +20,9 @@ meta-auth = "meta_api_grabber.auth:main"
meta-scheduled = "meta_api_grabber.scheduled_grabber:main"
meta-insights = "meta_api_grabber.insights_grabber:main"
meta-test-accounts = "meta_api_grabber.test_ad_accounts:main"
meta-test-leads = "meta_api_grabber.test_page_leads:main"
meta-token = "meta_api_grabber.token_manager:main"
google-ads-test = "meta_api_grabber.test_google_ads_accounts:main"
[build-system]
requires = ["hatchling"]

View File

@@ -3,23 +3,32 @@ Rate limiting and backoff mechanism for Meta Marketing API.
Based on Meta's best practices:
https://developers.facebook.com/docs/marketing-api/insights/best-practices/
https://developers.facebook.com/docs/graph-api/overview/rate-limiting
"""
import asyncio
import json
import logging
import time
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, List, Optional
from facebook_business.api import FacebookAdsApi
logger = logging.getLogger(__name__)
class MetaRateLimiter:
"""
Rate limiter with exponential backoff for Meta Marketing API.
Features:
- Monitors x-fb-ads-insights-throttle header
- Monitors X-App-Usage header (platform rate limits)
- Monitors X-Ad-Account-Usage header (ad account specific)
- Monitors X-Business-Use-Case-Usage header (business use case limits)
- Monitors x-fb-ads-insights-throttle header (legacy)
- Automatic throttling when usage > 75%
- Exponential backoff on rate limit errors
- Uses reset_time_duration and estimated_time_to_regain_access
- Configurable thresholds
"""
@@ -44,9 +53,25 @@ class MetaRateLimiter:
self.max_retry_delay = max_retry_delay
self.max_retries = max_retries
# Track current usage percentages
self.app_usage_pct: float = 0.0
self.account_usage_pct: float = 0.0
# Track current usage percentages from different headers
# X-App-Usage (platform rate limits)
self.app_call_count: float = 0.0
self.app_total_cputime: float = 0.0
self.app_total_time: float = 0.0
# X-Ad-Account-Usage (ad account specific)
self.ad_account_usage_pct: float = 0.0
self.reset_time_duration: int = 0 # seconds until reset
self.ads_api_access_tier: Optional[str] = None
# X-Business-Use-Case-Usage (business use case limits)
self.buc_usage: List[Dict[str, Any]] = []
self.estimated_time_to_regain_access: int = 0 # minutes
# Legacy x-fb-ads-insights-throttle
self.legacy_app_usage_pct: float = 0.0
self.legacy_account_usage_pct: float = 0.0
self.last_check_time: float = time.time()
# Stats
@@ -54,9 +79,134 @@ class MetaRateLimiter:
self.throttled_requests: int = 0
self.rate_limit_errors: int = 0
def _get_headers(self, response: Any) -> Optional[Dict[str, str]]:
"""
Extract headers from various response object types.
Args:
response: API response object
Returns:
Dictionary of headers or None
"""
# Facebook SDK response object
if hasattr(response, '_headers'):
return response._headers
elif hasattr(response, 'headers'):
return response.headers
elif hasattr(response, '_api_response'):
return getattr(response._api_response, 'headers', None)
return None
def parse_x_app_usage(self, response: Any) -> Dict[str, float]:
"""
Parse X-App-Usage header (Platform rate limits).
Header format: {"call_count": 28, "total_time": 25, "total_cputime": 25}
Args:
response: API response object
Returns:
Dictionary with call_count, total_time, total_cputime
"""
try:
headers = self._get_headers(response)
if headers:
header_value = headers.get('x-app-usage') or headers.get('X-App-Usage', '')
if header_value:
logger.debug(f"X-App-Usage header: {header_value}")
data = json.loads(header_value)
result = {
'call_count': float(data.get('call_count', 0)),
'total_time': float(data.get('total_time', 0)),
'total_cputime': float(data.get('total_cputime', 0)),
}
logger.debug(f"Parsed X-App-Usage: {result}")
return result
except Exception as e:
logger.debug(f"Failed to parse X-App-Usage header: {e}")
return {'call_count': 0.0, 'total_time': 0.0, 'total_cputime': 0.0}
def parse_x_ad_account_usage(self, response: Any) -> Dict[str, Any]:
"""
Parse X-Ad-Account-Usage header (Ad account specific limits).
Header format: {
"acc_id_util_pct": 9.67,
"reset_time_duration": 100,
"ads_api_access_tier": "standard_access"
}
Args:
response: API response object
Returns:
Dictionary with acc_id_util_pct, reset_time_duration, ads_api_access_tier
"""
try:
headers = self._get_headers(response)
if headers:
header_value = headers.get('x-ad-account-usage') or headers.get('X-Ad-Account-Usage', '')
if header_value:
logger.debug(f"X-Ad-Account-Usage header: {header_value}")
data = json.loads(header_value)
result = {
'acc_id_util_pct': float(data.get('acc_id_util_pct', 0)),
'reset_time_duration': int(data.get('reset_time_duration', 0)),
'ads_api_access_tier': data.get('ads_api_access_tier'),
}
logger.debug(f"Parsed X-Ad-Account-Usage: {result}")
return result
except Exception as e:
logger.debug(f"Failed to parse X-Ad-Account-Usage header: {e}")
return {'acc_id_util_pct': 0.0, 'reset_time_duration': 0, 'ads_api_access_tier': None}
def parse_x_business_use_case_usage(self, response: Any) -> List[Dict[str, Any]]:
"""
Parse X-Business-Use-Case-Usage header (Business use case limits).
Header format: {
"business-id": [{
"type": "ads_management",
"call_count": 95,
"total_cputime": 20,
"total_time": 20,
"estimated_time_to_regain_access": 0,
"ads_api_access_tier": "development_access"
}],
...
}
Args:
response: API response object
Returns:
List of usage dictionaries for each business use case
"""
try:
headers = self._get_headers(response)
if headers:
header_value = headers.get('x-business-use-case-usage') or headers.get('X-Business-Use-Case-Usage', '')
if header_value:
logger.debug(f"X-Business-Use-Case-Usage header: {header_value}")
data = json.loads(header_value)
# Flatten the nested structure
all_usage = []
for business_id, use_cases in data.items():
if isinstance(use_cases, list):
for use_case in use_cases:
use_case['business_id'] = business_id
all_usage.append(use_case)
logger.debug(f"Parsed X-Business-Use-Case-Usage: {len(all_usage)} use cases")
return all_usage
except Exception as e:
logger.debug(f"Failed to parse X-Business-Use-Case-Usage header: {e}")
return []
def parse_throttle_header(self, response: Any) -> Dict[str, float]:
"""
Parse x-fb-ads-insights-throttle header from response.
Parse x-fb-ads-insights-throttle header from response (legacy).
Header format: {"app_id_util_pct": 25.5, "acc_id_util_pct": 10.0}
@@ -67,49 +217,131 @@ class MetaRateLimiter:
Dictionary with app_id_util_pct and acc_id_util_pct
"""
try:
# Try to get the header from different response types
headers = None
# Facebook SDK response object
if hasattr(response, '_headers'):
headers = response._headers
elif hasattr(response, 'headers'):
headers = response.headers
elif hasattr(response, '_api_response'):
headers = getattr(response._api_response, 'headers', None)
headers = self._get_headers(response)
if headers:
throttle_header = headers.get('x-fb-ads-insights-throttle', '')
if throttle_header:
import json
logger.debug(f"x-fb-ads-insights-throttle header: {throttle_header}")
throttle_data = json.loads(throttle_header)
return {
result = {
'app_id_util_pct': float(throttle_data.get('app_id_util_pct', 0)),
'acc_id_util_pct': float(throttle_data.get('acc_id_util_pct', 0)),
}
logger.debug(f"Parsed x-fb-ads-insights-throttle: {result}")
return result
except Exception as e:
# Silently fail - we'll use conservative defaults
pass
logger.debug(f"Failed to parse x-fb-ads-insights-throttle header: {e}")
return {'app_id_util_pct': 0.0, 'acc_id_util_pct': 0.0}
def update_usage(self, response: Any):
"""
Update usage statistics from API response.
Update usage statistics from all API response headers.
Parses and updates metrics from:
- X-App-Usage
- X-Ad-Account-Usage
- X-Business-Use-Case-Usage
- x-fb-ads-insights-throttle (legacy)
Args:
response: API response object
"""
throttle_info = self.parse_throttle_header(response)
self.app_usage_pct = throttle_info['app_id_util_pct']
self.account_usage_pct = throttle_info['acc_id_util_pct']
# Parse all headers
app_usage = self.parse_x_app_usage(response)
ad_account_usage = self.parse_x_ad_account_usage(response)
buc_usage = self.parse_x_business_use_case_usage(response)
legacy_throttle = self.parse_throttle_header(response)
# Update X-App-Usage metrics
self.app_call_count = app_usage['call_count']
self.app_total_cputime = app_usage['total_cputime']
self.app_total_time = app_usage['total_time']
# Update X-Ad-Account-Usage metrics
self.ad_account_usage_pct = ad_account_usage['acc_id_util_pct']
self.reset_time_duration = ad_account_usage['reset_time_duration']
self.ads_api_access_tier = ad_account_usage['ads_api_access_tier']
# Update X-Business-Use-Case-Usage metrics
self.buc_usage = buc_usage
# Find the maximum estimated_time_to_regain_access across all use cases
if buc_usage:
self.estimated_time_to_regain_access = max(
(uc.get('estimated_time_to_regain_access', 0) for uc in buc_usage),
default=0
)
# Update legacy metrics
self.legacy_app_usage_pct = legacy_throttle['app_id_util_pct']
self.legacy_account_usage_pct = legacy_throttle['acc_id_util_pct']
self.last_check_time = time.time()
# Log if we're approaching limits
max_usage = max(self.app_usage_pct, self.account_usage_pct)
if max_usage > self.throttle_threshold:
print(f"\n⚠️ Rate limit warning: {max_usage:.1f}% usage")
print(f" App: {self.app_usage_pct:.1f}%, Account: {self.account_usage_pct:.1f}%")
# Log warnings if approaching limits
self._log_rate_limit_warnings()
def _log_rate_limit_warnings(self):
"""Log warnings if any rate limit metric is approaching threshold."""
warnings = []
# Check X-App-Usage metrics
if self.app_call_count > self.throttle_threshold:
warnings.append(f"App call count: {self.app_call_count:.1f}%")
if self.app_total_cputime > self.throttle_threshold:
warnings.append(f"App CPU time: {self.app_total_cputime:.1f}%")
if self.app_total_time > self.throttle_threshold:
warnings.append(f"App total time: {self.app_total_time:.1f}%")
# Check X-Ad-Account-Usage
if self.ad_account_usage_pct > self.throttle_threshold:
warnings.append(f"Ad account: {self.ad_account_usage_pct:.1f}%")
if self.reset_time_duration > 0:
warnings.append(f"Resets in {self.reset_time_duration}s")
# Check X-Business-Use-Case-Usage
for buc in self.buc_usage:
buc_type = buc.get('type', 'unknown')
call_count = buc.get('call_count', 0)
if call_count > self.throttle_threshold:
warnings.append(f"BUC {buc_type}: {call_count:.1f}%")
eta = buc.get('estimated_time_to_regain_access', 0)
if eta > 0:
warnings.append(f"Regain access in {eta} min")
# Check legacy metrics
if self.legacy_app_usage_pct > self.throttle_threshold:
warnings.append(f"Legacy app: {self.legacy_app_usage_pct:.1f}%")
if self.legacy_account_usage_pct > self.throttle_threshold:
warnings.append(f"Legacy account: {self.legacy_account_usage_pct:.1f}%")
if warnings:
logger.warning(f"⚠️ Rate limit warning: {', '.join(warnings)}")
def get_max_usage_pct(self) -> float:
"""
Get the maximum usage percentage across all rate limit metrics.
Returns:
Maximum usage percentage (0-100)
"""
usage_values = [
self.app_call_count,
self.app_total_cputime,
self.app_total_time,
self.ad_account_usage_pct,
self.legacy_app_usage_pct,
self.legacy_account_usage_pct,
]
# Add BUC usage percentages
for buc in self.buc_usage:
usage_values.extend([
buc.get('call_count', 0),
buc.get('total_cputime', 0),
buc.get('total_time', 0),
])
return max(usage_values) if usage_values else 0.0
def should_throttle(self) -> bool:
"""
@@ -118,21 +350,36 @@ class MetaRateLimiter:
Returns:
True if usage exceeds threshold
"""
max_usage = max(self.app_usage_pct, self.account_usage_pct)
return max_usage > self.throttle_threshold
return self.get_max_usage_pct() > self.throttle_threshold
def get_throttle_delay(self) -> float:
"""
Calculate delay based on current usage.
Calculate delay based on current usage and reset times.
Uses estimated_time_to_regain_access and reset_time_duration when available.
Returns:
Delay in seconds
"""
max_usage = max(self.app_usage_pct, self.account_usage_pct)
max_usage = self.get_max_usage_pct()
if max_usage < self.throttle_threshold:
return self.base_delay
# If we have estimated_time_to_regain_access from BUC header, use it
if self.estimated_time_to_regain_access > 0:
# Convert minutes to seconds and use as delay
delay = self.estimated_time_to_regain_access * 60
logger.info(f"Using BUC estimated_time_to_regain_access: {self.estimated_time_to_regain_access} min ({delay}s)")
return min(delay, self.max_retry_delay)
# If we have reset_time_duration from Ad Account header, consider it
if self.reset_time_duration > 0 and self.ad_account_usage_pct >= 90:
# Use a fraction of reset_time_duration as delay
delay = min(self.reset_time_duration * 0.5, self.max_retry_delay)
logger.info(f"Using Ad Account reset_time_duration: {self.reset_time_duration}s (delay: {delay}s)")
return delay
# Progressive delay based on usage
# 75% = base_delay, 90% = 2x, 95% = 5x, 99% = 10x
if max_usage >= 95:
@@ -166,7 +413,8 @@ class MetaRateLimiter:
if delay > self.base_delay:
self.throttled_requests += 1
print(f"⏸️ Throttling for {delay:.1f}s (usage: {max(self.app_usage_pct, self.account_usage_pct):.1f}%)")
max_usage = self.get_max_usage_pct()
logger.info(f"⏸️ Throttling for {delay:.1f}s (max usage: {max_usage:.1f}%)")
await asyncio.sleep(delay)
@@ -209,13 +457,25 @@ class MetaRateLimiter:
except Exception as e:
error_message = str(e).lower()
# Check if it's a rate limit error
# Check if it's a rate limit error (expanded list based on Meta docs)
is_rate_limit = (
'rate limit' in error_message or
'too many requests' in error_message or
'throttle' in error_message or
'error code 17' in error_message or # Meta's rate limit error code
'error code 80004' in error_message # Insights rate limit
'error code 4' in error_message or # App rate limit
'error code 17' in error_message or # User rate limit
'error code 32' in error_message or # Pages rate limit
'error code 613' in error_message or # Custom rate limit
'error code 80000' in error_message or # Ads Insights BUC
'error code 80001' in error_message or # Pages BUC
'error code 80002' in error_message or # Instagram BUC
'error code 80003' in error_message or # Custom Audience BUC
'error code 80004' in error_message or # Ads Management BUC
'error code 80005' in error_message or # LeadGen BUC
'error code 80006' in error_message or # Messenger BUC
'error code 80008' in error_message or # WhatsApp BUC
'error code 80009' in error_message or # Catalog Management BUC
'error code 80014' in error_message # Catalog Batch BUC
)
if is_rate_limit:
@@ -226,12 +486,12 @@ class MetaRateLimiter:
(2 ** (retry + 1)) * self.base_delay,
self.max_retry_delay
)
print(f"\n🔄 Rate limit hit! Retrying in {backoff_delay:.1f}s (attempt {retry + 1}/{self.max_retries})")
print(f" Error: {e}")
logger.warning(f"🔄 Rate limit hit! Retrying in {backoff_delay:.1f}s (attempt {retry + 1}/{self.max_retries})")
logger.warning(f" Error: {e}")
await asyncio.sleep(backoff_delay)
continue
else:
print(f"\n❌ Rate limit error - max retries exhausted")
logger.error(f"❌ Rate limit error - max retries exhausted: {e}")
raise
# Not a rate limit error, re-raise immediately
@@ -245,29 +505,86 @@ class MetaRateLimiter:
Get current rate limiter statistics.
Returns:
Dictionary with stats
Dictionary with comprehensive stats from all headers
"""
return {
# Request stats
'total_requests': self.total_requests,
'throttled_requests': self.throttled_requests,
'rate_limit_errors': self.rate_limit_errors,
'app_usage_pct': self.app_usage_pct,
'account_usage_pct': self.account_usage_pct,
'max_usage_pct': max(self.app_usage_pct, self.account_usage_pct),
# X-App-Usage metrics
'app_call_count': self.app_call_count,
'app_total_cputime': self.app_total_cputime,
'app_total_time': self.app_total_time,
# X-Ad-Account-Usage metrics
'ad_account_usage_pct': self.ad_account_usage_pct,
'reset_time_duration': self.reset_time_duration,
'ads_api_access_tier': self.ads_api_access_tier,
# X-Business-Use-Case-Usage metrics
'buc_usage': self.buc_usage,
'estimated_time_to_regain_access': self.estimated_time_to_regain_access,
# Legacy metrics
'legacy_app_usage_pct': self.legacy_app_usage_pct,
'legacy_account_usage_pct': self.legacy_account_usage_pct,
# Computed metrics
'max_usage_pct': self.get_max_usage_pct(),
'is_throttling': self.should_throttle(),
}
def print_stats(self):
"""Print current statistics."""
"""Print current statistics with all rate limit metrics."""
stats = self.get_stats()
print("\n" + "="*60)
print("RATE LIMITER STATISTICS")
print("="*60)
print(f"Total Requests: {stats['total_requests']}")
print(f"Throttled Requests: {stats['throttled_requests']}")
print(f"Rate Limit Errors: {stats['rate_limit_errors']}")
print(f"App Usage: {stats['app_usage_pct']:.1f}%")
print(f"Account Usage: {stats['account_usage_pct']:.1f}%")
print(f"Max Usage: {stats['max_usage_pct']:.1f}%")
print(f"Currently Throttled: {stats['is_throttling']}")
print("="*60 + "\n")
output = []
output.append("\n" + "="*70)
output.append("RATE LIMITER STATISTICS")
output.append("="*70)
# Request stats
output.append(f"Total Requests: {stats['total_requests']}")
output.append(f"Throttled Requests: {stats['throttled_requests']}")
output.append(f"Rate Limit Errors: {stats['rate_limit_errors']}")
output.append("")
# X-App-Usage
output.append("X-App-Usage (Platform Rate Limits):")
output.append(f" Call Count: {stats['app_call_count']:.1f}%")
output.append(f" Total CPU Time: {stats['app_total_cputime']:.1f}%")
output.append(f" Total Time: {stats['app_total_time']:.1f}%")
output.append("")
# X-Ad-Account-Usage
output.append("X-Ad-Account-Usage:")
output.append(f" Account Usage: {stats['ad_account_usage_pct']:.1f}%")
output.append(f" Reset Time Duration: {stats['reset_time_duration']}s")
output.append(f" API Access Tier: {stats['ads_api_access_tier'] or 'N/A'}")
output.append("")
# X-Business-Use-Case-Usage
if stats['buc_usage']:
output.append("X-Business-Use-Case-Usage:")
for buc in stats['buc_usage']:
output.append(f" Type: {buc.get('type', 'unknown')}")
output.append(f" Call Count: {buc.get('call_count', 0):.1f}%")
output.append(f" Total CPU Time: {buc.get('total_cputime', 0):.1f}%")
output.append(f" Total Time: {buc.get('total_time', 0):.1f}%")
output.append(f" Est. Time to Regain: {buc.get('estimated_time_to_regain_access', 0)} min")
output.append("")
# Legacy metrics
output.append("Legacy (x-fb-ads-insights-throttle):")
output.append(f" App Usage: {stats['legacy_app_usage_pct']:.1f}%")
output.append(f" Account Usage: {stats['legacy_account_usage_pct']:.1f}%")
output.append("")
# Summary
output.append(f"Max Usage Across All Metrics: {stats['max_usage_pct']:.1f}%")
output.append(f"Currently Throttled: {stats['is_throttling']}")
output.append("="*70 + "\n")
logger.info("\n".join(output))

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test script to grab ad accounts from Google Ads API.
This script reads configuration from google-ads.yaml and authenticates using
a service account JSON key file to retrieve accessible customer accounts.
"""
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
import sys
def list_accessible_customers(client):
"""Lists all customer IDs accessible to the authenticated user.
Args:
client: An initialized GoogleAdsClient instance.
Returns:
List of customer resource names.
"""
customer_service = client.get_service("CustomerService")
try:
accessible_customers = customer_service.list_accessible_customers()
print(f"\nFound {len(accessible_customers.resource_names)} accessible customers:")
for resource_name in accessible_customers.resource_names:
customer_id = resource_name.split('/')[-1]
print(f" - Customer ID: {customer_id}")
print(f" Resource Name: {resource_name}")
return accessible_customers.resource_names
except GoogleAdsException as ex:
print(f"Request failed with status {ex.error.code().name}")
for error in ex.failure.errors:
print(f"\tError: {error.message}")
if error.location:
for field in error.location.field_path_elements:
print(f"\t\tField: {field.field_name}")
sys.exit(1)
def get_customer_details(client, customer_id):
"""Retrieves detailed information about a customer account.
Args:
client: An initialized GoogleAdsClient instance.
customer_id: The customer ID (without dashes).
"""
ga_service = client.get_service("GoogleAdsService")
query = """
SELECT
customer.id,
customer.descriptive_name,
customer.currency_code,
customer.time_zone,
customer.manager,
customer.test_account
FROM customer
WHERE customer.id = {customer_id}
""".format(customer_id=customer_id)
try:
response = ga_service.search(customer_id=customer_id, query=query)
for row in response:
customer = row.customer
print(f"\n--- Customer Details for {customer_id} ---")
print(f" ID: {customer.id}")
print(f" Name: {customer.descriptive_name}")
print(f" Currency: {customer.currency_code}")
print(f" Time Zone: {customer.time_zone}")
print(f" Is Manager: {customer.manager}")
print(f" Is Test Account: {customer.test_account}")
except GoogleAdsException as ex:
print(f"\nFailed to get details for customer {customer_id}")
print(f"Status: {ex.error.code().name}")
for error in ex.failure.errors:
print(f" Error: {error.message}")
def main():
"""Main function to test Google Ads API connection and list accounts."""
print("=" * 60)
print("Google Ads API - Account Listing Test")
print("=" * 60)
# Load client from YAML configuration
# By default, this looks for google-ads.yaml in the current directory
# or in the home directory
try:
print("\nLoading Google Ads client from configuration...")
client = GoogleAdsClient.load_from_storage(path="google-ads.yaml")
print("✓ Client loaded successfully")
except Exception as e:
print(f"✗ Failed to load client: {e}")
print("\nPlease ensure:")
print(" 1. google-ads.yaml exists and is properly configured")
print(" 2. google_ads_key.json exists and contains valid credentials")
print(" 3. All required fields are filled in google-ads.yaml")
sys.exit(1)
# List accessible customers
print("\n" + "=" * 60)
print("Listing Accessible Customers")
print("=" * 60)
resource_names = list_accessible_customers(client)
# Get detailed information for each customer
if resource_names:
print("\n" + "=" * 60)
print("Customer Details")
print("=" * 60)
for resource_name in resource_names:
customer_id = resource_name.split('/')[-1]
get_customer_details(client, customer_id)
print("\n" + "=" * 60)
print("Test completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
"""
Test script to retrieve leads from a specific Facebook page.
This uses the existing Meta API credentials to test leads retrieval.
"""
import asyncio
import os
from datetime import datetime
from dotenv import load_dotenv
from facebook_business.api import FacebookAdsApi
from facebook_business.adobjects.page import Page
async def test_page_leads():
"""Test retrieving leads from a specific Facebook page."""
load_dotenv()
# Get credentials from environment
access_token = os.getenv("META_ACCESS_TOKEN")
app_secret = os.getenv("META_APP_SECRET")
app_id = os.getenv("META_APP_ID")
if not all([access_token, app_secret, app_id]):
print("❌ Missing required environment variables")
print(" Please ensure META_ACCESS_TOKEN, META_APP_SECRET, and META_APP_ID")
print(" are set in .env")
return 1
print("="*60)
print("PAGE LEADS TEST - RETRIEVING LEADS FROM A SPECIFIC PAGE")
print("="*60)
print()
# Initialize Facebook Ads API
FacebookAdsApi.init(
app_id=app_id,
app_secret=app_secret,
access_token=access_token,
)
# Prompt for page ID
page_id = input("Enter the Facebook Page ID: ").strip()
if not page_id:
print("❌ No page ID provided")
return 1
try:
# Initialize the Page object
print(f"\nConnecting to Page {page_id}...")
page = Page(fbid=page_id)
# First, get basic page information to verify access
print("\nFetching page information...")
page_fields = ['name', 'id', 'access_token']
page_info = page.api_get(fields=page_fields)
print(f"\n✓ Page Information:")
print(f" Name: {page_info.get('name', 'N/A')}")
print(f" ID: {page_info.get('id', 'N/A')}")
print(f" Has Access Token: {'Yes' if page_info.get('access_token') else 'No'}")
# Get leadgen forms associated with this page
print("\n" + "="*60)
print("Fetching Lead Generation Forms...")
print("="*60)
leadgen_forms = page.get_lead_gen_forms(
fields=['id', 'name', 'status', 'leads_count', 'created_time']
)
if not leadgen_forms or len(leadgen_forms) == 0:
print("\n⚠️ No lead generation forms found for this page")
print(" This could mean:")
print(" 1. The page has no lead forms")
print(" 2. The access token doesn't have permission to view lead forms")
print(" 3. The page ID is incorrect")
return 0
print(f"\nFound {len(leadgen_forms)} lead generation form(s):\n")
total_leads = 0
for idx, form in enumerate(leadgen_forms, 1):
form_id = form.get('id')
form_name = form.get('name', 'N/A')
form_status = form.get('status', 'N/A')
leads_count = form.get('leads_count', 0)
created_time = form.get('created_time', 'N/A')
print(f"Form {idx}:")
print(f" ID: {form_id}")
print(f" Name: {form_name}")
print(f" Status: {form_status}")
print(f" Leads Count: {leads_count}")
print(f" Created: {created_time}")
# Try to fetch actual leads from this form
try:
print(f"\n Fetching leads from form '{form_name}'...")
# Get the form object to retrieve leads
from facebook_business.adobjects.leadgenform import LeadgenForm
form_obj = LeadgenForm(fbid=form_id)
leads = form_obj.get_leads(
fields=['id', 'created_time', 'field_data']
)
leads_list = list(leads)
print(f" ✓ Retrieved {len(leads_list)} lead(s)")
if leads_list:
print(f"\n Sample leads from '{form_name}':")
for lead_idx, lead in enumerate(leads_list[:5], 1): # Show first 5 leads
lead_id = lead.get('id')
lead_created = lead.get('created_time', 'N/A')
field_data = lead.get('field_data', [])
print(f"\n Lead {lead_idx}:")
print(f" ID: {lead_id}")
print(f" Created: {lead_created}")
print(f" Fields:")
for field in field_data:
field_name = field.get('name', 'unknown')
field_values = field.get('values', [])
print(f" {field_name}: {', '.join(field_values)}")
if len(leads_list) > 5:
print(f"\n ... and {len(leads_list) - 5} more lead(s)")
total_leads += len(leads_list)
except Exception as lead_error:
print(f" ❌ Error fetching leads: {lead_error}")
print()
print("="*60)
print("TEST COMPLETED")
print("="*60)
print(f"\n✓ Total forms found: {len(leadgen_forms)}")
print(f"✓ Total leads retrieved: {total_leads}")
if total_leads == 0:
print("\n⚠️ No leads were retrieved. This could mean:")
print(" 1. The forms have no leads yet")
print(" 2. Your access token needs 'leads_retrieval' permission")
print(" 3. You need to request advanced access for leads_retrieval")
print("\nRequired permissions:")
print(" - pages_manage_ads")
print(" - pages_read_engagement")
print(" - leads_retrieval")
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
return 1
return 0
def main():
"""Entry point for the test script."""
exit_code = asyncio.run(test_page_leads())
exit(exit_code)
if __name__ == "__main__":
main()

156
test_rate_limiter.py Normal file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Test script for the enhanced Meta API rate limiter.
This demonstrates the rate limiter's ability to parse and monitor
all Meta API rate limit headers.
"""
import asyncio
import json
import logging
from dataclasses import dataclass
from typing import Dict, Optional
from src.meta_api_grabber.rate_limiter import MetaRateLimiter
# Configure logging to show debug messages
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
@dataclass
class MockResponse:
"""Mock API response with rate limit headers."""
headers: Dict[str, str]
async def test_rate_limiter():
"""Test the rate limiter with various header scenarios."""
# Initialize rate limiter
limiter = MetaRateLimiter(
base_delay=1.0,
throttle_threshold=75.0,
max_retry_delay=60.0,
)
print("\n" + "="*70)
print("TESTING ENHANCED META API RATE LIMITER")
print("="*70 + "\n")
# Test 1: X-App-Usage header
print("\n--- Test 1: X-App-Usage Header ---")
response1 = MockResponse(headers={
'x-app-usage': json.dumps({
'call_count': 45,
'total_time': 30,
'total_cputime': 35
})
})
limiter.update_usage(response1)
limiter.print_stats()
# Test 2: X-Ad-Account-Usage header
print("\n--- Test 2: X-Ad-Account-Usage Header ---")
response2 = MockResponse(headers={
'x-ad-account-usage': json.dumps({
'acc_id_util_pct': 78.5,
'reset_time_duration': 120,
'ads_api_access_tier': 'development_access'
})
})
limiter.update_usage(response2)
limiter.print_stats()
# Test 3: X-Business-Use-Case-Usage header
print("\n--- Test 3: X-Business-Use-Case-Usage Header ---")
response3 = MockResponse(headers={
'x-business-use-case-usage': json.dumps({
'66782684': [{
'type': 'ads_management',
'call_count': 85,
'total_cputime': 40,
'total_time': 35,
'estimated_time_to_regain_access': 5,
'ads_api_access_tier': 'development_access'
}],
'10153848260347724': [{
'type': 'ads_insights',
'call_count': 92,
'total_cputime': 50,
'total_time': 45,
'estimated_time_to_regain_access': 8,
'ads_api_access_tier': 'development_access'
}]
})
})
limiter.update_usage(response3)
limiter.print_stats()
# Test 4: Legacy x-fb-ads-insights-throttle header
print("\n--- Test 4: Legacy Header ---")
response4 = MockResponse(headers={
'x-fb-ads-insights-throttle': json.dumps({
'app_id_util_pct': 65.0,
'acc_id_util_pct': 70.5
})
})
limiter.update_usage(response4)
limiter.print_stats()
# Test 5: All headers combined (high usage scenario)
print("\n--- Test 5: High Usage Scenario (All Headers) ---")
response5 = MockResponse(headers={
'x-app-usage': json.dumps({
'call_count': 95,
'total_time': 88,
'total_cputime': 90
}),
'x-ad-account-usage': json.dumps({
'acc_id_util_pct': 97.5,
'reset_time_duration': 300,
'ads_api_access_tier': 'standard_access'
}),
'x-business-use-case-usage': json.dumps({
'12345678': [{
'type': 'ads_insights',
'call_count': 98,
'total_cputime': 95,
'total_time': 92,
'estimated_time_to_regain_access': 15,
'ads_api_access_tier': 'standard_access'
}]
}),
'x-fb-ads-insights-throttle': json.dumps({
'app_id_util_pct': 93.0,
'acc_id_util_pct': 96.0
})
})
limiter.update_usage(response5)
limiter.print_stats()
# Test throttling behavior
print("\n--- Test 6: Throttling Behavior ---")
print(f"Should throttle: {limiter.should_throttle()}")
print(f"Max usage: {limiter.get_max_usage_pct():.1f}%")
print(f"Throttle delay: {limiter.get_throttle_delay():.1f}s")
print(f"Estimated time to regain access: {limiter.estimated_time_to_regain_access} min")
print(f"Reset time duration: {limiter.reset_time_duration}s")
# Test 7: Empty/missing headers
print("\n--- Test 7: Missing Headers ---")
response6 = MockResponse(headers={})
limiter.update_usage(response6)
limiter.print_stats()
print("\n" + "="*70)
print("ALL TESTS COMPLETED")
print("="*70 + "\n")
if __name__ == '__main__':
asyncio.run(test_rate_limiter())

230
uv.lock generated
View File

@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "aiohappyeyeballs"
@@ -130,6 +134,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "cachetools"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
@@ -281,6 +294,80 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "google-ads"
version = "28.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth-oauthlib" },
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "grpcio-status" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/ee/30bf06a8334333a43805050c7530637b7c308f371945e3cad7d78b4c5287/google_ads-28.3.0.tar.gz", hash = "sha256:d544e7e3792974e9dc6a016e0eb264f9218526be698c8c6b8a438717a6dcc95b", size = 9222858, upload-time = "2025-10-22T16:22:43.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/cf/21541e673e47582ac46b164817ff359370ed9897db977225587f5290b202/google_ads-28.3.0-py3-none-any.whl", hash = "sha256:11ec6227784a565de3ad3f0047ac82eb13c6bfca1d4a5862df9b3c63162fbb40", size = 17781520, upload-time = "2025-10-22T16:22:40.881Z" },
]
[[package]]
name = "google-api-core"
version = "2.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/da/83d7043169ac2c8c7469f0e375610d78ae2160134bf1b80634c482fa079c/google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", size = 176759, upload-time = "2025-10-28T21:34:51.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
]
[[package]]
name = "google-auth"
version = "2.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/75/28881e9d7de9b3d61939bc9624bd8fa594eb787a00567aba87173c790f09/google_auth-2.42.0.tar.gz", hash = "sha256:9bbbeef3442586effb124d1ca032cfb8fb7acd8754ab79b55facd2b8f3ab2802", size = 295400, upload-time = "2025-10-28T17:38:08.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/24/ec82aee6ba1a076288818fe5cc5125f4d93fffdc68bb7b381c68286c8aaa/google_auth-2.42.0-py2.py3-none-any.whl", hash = "sha256:f8f944bcb9723339b0ef58a73840f3c61bc91b69bf7368464906120b55804473", size = 222550, upload-time = "2025-10-28T17:38:05.496Z" },
]
[[package]]
name = "google-auth-oauthlib"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "requests-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955, upload-time = "2025-04-22T16:40:29.172Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.71.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/43/b25abe02db2911397819003029bef768f68a974f2ece483e6084d1a5f754/googleapis_common_protos-1.71.0.tar.gz", hash = "sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e", size = 146454, upload-time = "2025-10-20T14:58:08.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/e8/eba9fece11d57a71e3e22ea672742c8f3cf23b35730c9e96db768b295216/googleapis_common_protos-1.71.0-py3-none-any.whl", hash = "sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c", size = 294576, upload-time = "2025-10-20T14:56:21.295Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -305,6 +392,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
{ url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
{ url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
{ url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
{ url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
{ url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
{ url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
{ url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
{ url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
{ url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
{ url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
{ url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
{ url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
]
[[package]]
name = "grpcio-status"
version = "1.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -387,6 +519,7 @@ dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "facebook-business" },
{ name = "google-ads" },
{ name = "python-dotenv" },
{ name = "requests-oauthlib" },
{ name = "sqlalchemy", extra = ["asyncio"] },
@@ -398,6 +531,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" },
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "facebook-business", specifier = ">=23.0.3" },
{ name = "google-ads", specifier = ">=28.3.0" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "requests-oauthlib", specifier = ">=2.0.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.44" },
@@ -562,6 +696,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
[[package]]
name = "proto-plus"
version = "1.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" },
]
[[package]]
name = "protobuf"
version = "6.33.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" },
{ url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" },
{ url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" },
{ url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" },
{ url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycountry"
version = "24.6.1"
@@ -580,6 +762,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@@ -608,6 +826,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "six"
version = "1.17.0"