diff --git a/RATE_LIMITER_ENHANCEMENTS.md b/RATE_LIMITER_ENHANCEMENTS.md new file mode 100644 index 0000000..e824cc8 --- /dev/null +++ b/RATE_LIMITER_ENHANCEMENTS.md @@ -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/) diff --git a/analyticsdashboard-476613-eb23490ceed8.json b/analyticsdashboard-476613-eb23490ceed8.json new file mode 100644 index 0000000..48708e6 --- /dev/null +++ b/analyticsdashboard-476613-eb23490ceed8.json @@ -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" +} diff --git a/google-ads.yaml b/google-ads.yaml new file mode 100644 index 0000000..531739e --- /dev/null +++ b/google-ads.yaml @@ -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 diff --git a/google_ads_key.json b/google_ads_key.json new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 16fdf6b..c4c348f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/meta_api_grabber/rate_limiter.py b/src/meta_api_grabber/rate_limiter.py index 1063624..be596e8 100644 --- a/src/meta_api_grabber/rate_limiter.py +++ b/src/meta_api_grabber/rate_limiter.py @@ -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)) diff --git a/src/meta_api_grabber/test_google_ads_accounts.py b/src/meta_api_grabber/test_google_ads_accounts.py new file mode 100644 index 0000000..c5fe603 --- /dev/null +++ b/src/meta_api_grabber/test_google_ads_accounts.py @@ -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() diff --git a/src/meta_api_grabber/test_page_leads.py b/src/meta_api_grabber/test_page_leads.py new file mode 100644 index 0000000..91f79e3 --- /dev/null +++ b/src/meta_api_grabber/test_page_leads.py @@ -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() diff --git a/test_rate_limiter.py b/test_rate_limiter.py new file mode 100644 index 0000000..a47590f --- /dev/null +++ b/test_rate_limiter.py @@ -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()) diff --git a/uv.lock b/uv.lock index 4719e68..e919d6a 100644 --- a/uv.lock +++ b/uv.lock @@ -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"