Merge branch 'main' of https://gitea.99tales.net/jonas/meta_api_grabber
This commit is contained in:
193
RATE_LIMITER_ENHANCEMENTS.md
Normal file
193
RATE_LIMITER_ENHANCEMENTS.md
Normal 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/)
|
||||||
13
analyticsdashboard-476613-eb23490ceed8.json
Normal file
13
analyticsdashboard-476613-eb23490ceed8.json
Normal 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
31
google-ads.yaml
Normal 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
0
google_ads_key.json
Normal file
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"alembic>=1.17.0",
|
"alembic>=1.17.0",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"facebook-business>=23.0.3",
|
"facebook-business>=23.0.3",
|
||||||
|
"google-ads>=28.3.0",
|
||||||
"python-dotenv>=1.1.1",
|
"python-dotenv>=1.1.1",
|
||||||
"requests-oauthlib>=2.0.0",
|
"requests-oauthlib>=2.0.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.44",
|
"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-scheduled = "meta_api_grabber.scheduled_grabber:main"
|
||||||
meta-insights = "meta_api_grabber.insights_grabber:main"
|
meta-insights = "meta_api_grabber.insights_grabber:main"
|
||||||
meta-test-accounts = "meta_api_grabber.test_ad_accounts: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"
|
meta-token = "meta_api_grabber.token_manager:main"
|
||||||
|
google-ads-test = "meta_api_grabber.test_google_ads_accounts:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -3,23 +3,32 @@ Rate limiting and backoff mechanism for Meta Marketing API.
|
|||||||
|
|
||||||
Based on Meta's best practices:
|
Based on Meta's best practices:
|
||||||
https://developers.facebook.com/docs/marketing-api/insights/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 asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from facebook_business.api import FacebookAdsApi
|
from facebook_business.api import FacebookAdsApi
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MetaRateLimiter:
|
class MetaRateLimiter:
|
||||||
"""
|
"""
|
||||||
Rate limiter with exponential backoff for Meta Marketing API.
|
Rate limiter with exponential backoff for Meta Marketing API.
|
||||||
|
|
||||||
Features:
|
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%
|
- Automatic throttling when usage > 75%
|
||||||
- Exponential backoff on rate limit errors
|
- Exponential backoff on rate limit errors
|
||||||
|
- Uses reset_time_duration and estimated_time_to_regain_access
|
||||||
- Configurable thresholds
|
- Configurable thresholds
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -44,9 +53,25 @@ class MetaRateLimiter:
|
|||||||
self.max_retry_delay = max_retry_delay
|
self.max_retry_delay = max_retry_delay
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
|
|
||||||
# Track current usage percentages
|
# Track current usage percentages from different headers
|
||||||
self.app_usage_pct: float = 0.0
|
# X-App-Usage (platform rate limits)
|
||||||
self.account_usage_pct: float = 0.0
|
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()
|
self.last_check_time: float = time.time()
|
||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
@@ -54,9 +79,134 @@ class MetaRateLimiter:
|
|||||||
self.throttled_requests: int = 0
|
self.throttled_requests: int = 0
|
||||||
self.rate_limit_errors: 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]:
|
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}
|
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
|
Dictionary with app_id_util_pct and acc_id_util_pct
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try to get the header from different response types
|
headers = self._get_headers(response)
|
||||||
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)
|
|
||||||
|
|
||||||
if headers:
|
if headers:
|
||||||
throttle_header = headers.get('x-fb-ads-insights-throttle', '')
|
throttle_header = headers.get('x-fb-ads-insights-throttle', '')
|
||||||
if throttle_header:
|
if throttle_header:
|
||||||
import json
|
logger.debug(f"x-fb-ads-insights-throttle header: {throttle_header}")
|
||||||
throttle_data = json.loads(throttle_header)
|
throttle_data = json.loads(throttle_header)
|
||||||
return {
|
result = {
|
||||||
'app_id_util_pct': float(throttle_data.get('app_id_util_pct', 0)),
|
'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)),
|
'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:
|
except Exception as e:
|
||||||
# Silently fail - we'll use conservative defaults
|
logger.debug(f"Failed to parse x-fb-ads-insights-throttle header: {e}")
|
||||||
pass
|
|
||||||
|
|
||||||
return {'app_id_util_pct': 0.0, 'acc_id_util_pct': 0.0}
|
return {'app_id_util_pct': 0.0, 'acc_id_util_pct': 0.0}
|
||||||
|
|
||||||
def update_usage(self, response: Any):
|
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:
|
Args:
|
||||||
response: API response object
|
response: API response object
|
||||||
"""
|
"""
|
||||||
throttle_info = self.parse_throttle_header(response)
|
# Parse all headers
|
||||||
self.app_usage_pct = throttle_info['app_id_util_pct']
|
app_usage = self.parse_x_app_usage(response)
|
||||||
self.account_usage_pct = throttle_info['acc_id_util_pct']
|
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()
|
self.last_check_time = time.time()
|
||||||
|
|
||||||
# Log if we're approaching limits
|
# Log warnings if approaching limits
|
||||||
max_usage = max(self.app_usage_pct, self.account_usage_pct)
|
self._log_rate_limit_warnings()
|
||||||
if max_usage > self.throttle_threshold:
|
|
||||||
print(f"\n⚠️ Rate limit warning: {max_usage:.1f}% usage")
|
def _log_rate_limit_warnings(self):
|
||||||
print(f" App: {self.app_usage_pct:.1f}%, Account: {self.account_usage_pct:.1f}%")
|
"""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:
|
def should_throttle(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -118,21 +350,36 @@ class MetaRateLimiter:
|
|||||||
Returns:
|
Returns:
|
||||||
True if usage exceeds threshold
|
True if usage exceeds threshold
|
||||||
"""
|
"""
|
||||||
max_usage = max(self.app_usage_pct, self.account_usage_pct)
|
return self.get_max_usage_pct() > self.throttle_threshold
|
||||||
return max_usage > self.throttle_threshold
|
|
||||||
|
|
||||||
def get_throttle_delay(self) -> float:
|
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:
|
Returns:
|
||||||
Delay in seconds
|
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:
|
if max_usage < self.throttle_threshold:
|
||||||
return self.base_delay
|
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
|
# Progressive delay based on usage
|
||||||
# 75% = base_delay, 90% = 2x, 95% = 5x, 99% = 10x
|
# 75% = base_delay, 90% = 2x, 95% = 5x, 99% = 10x
|
||||||
if max_usage >= 95:
|
if max_usage >= 95:
|
||||||
@@ -166,7 +413,8 @@ class MetaRateLimiter:
|
|||||||
|
|
||||||
if delay > self.base_delay:
|
if delay > self.base_delay:
|
||||||
self.throttled_requests += 1
|
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)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
@@ -209,13 +457,25 @@ class MetaRateLimiter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = str(e).lower()
|
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 = (
|
is_rate_limit = (
|
||||||
'rate limit' in error_message or
|
'rate limit' in error_message or
|
||||||
'too many requests' in error_message or
|
'too many requests' in error_message or
|
||||||
'throttle' in error_message or
|
'throttle' in error_message or
|
||||||
'error code 17' in error_message or # Meta's rate limit error code
|
'error code 4' in error_message or # App rate limit
|
||||||
'error code 80004' in error_message # Insights 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:
|
if is_rate_limit:
|
||||||
@@ -226,12 +486,12 @@ class MetaRateLimiter:
|
|||||||
(2 ** (retry + 1)) * self.base_delay,
|
(2 ** (retry + 1)) * self.base_delay,
|
||||||
self.max_retry_delay
|
self.max_retry_delay
|
||||||
)
|
)
|
||||||
print(f"\n🔄 Rate limit hit! Retrying in {backoff_delay:.1f}s (attempt {retry + 1}/{self.max_retries})")
|
logger.warning(f"🔄 Rate limit hit! Retrying in {backoff_delay:.1f}s (attempt {retry + 1}/{self.max_retries})")
|
||||||
print(f" Error: {e}")
|
logger.warning(f" Error: {e}")
|
||||||
await asyncio.sleep(backoff_delay)
|
await asyncio.sleep(backoff_delay)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
print(f"\n❌ Rate limit error - max retries exhausted")
|
logger.error(f"❌ Rate limit error - max retries exhausted: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Not a rate limit error, re-raise immediately
|
# Not a rate limit error, re-raise immediately
|
||||||
@@ -245,29 +505,86 @@ class MetaRateLimiter:
|
|||||||
Get current rate limiter statistics.
|
Get current rate limiter statistics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with stats
|
Dictionary with comprehensive stats from all headers
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
|
# Request stats
|
||||||
'total_requests': self.total_requests,
|
'total_requests': self.total_requests,
|
||||||
'throttled_requests': self.throttled_requests,
|
'throttled_requests': self.throttled_requests,
|
||||||
'rate_limit_errors': self.rate_limit_errors,
|
'rate_limit_errors': self.rate_limit_errors,
|
||||||
'app_usage_pct': self.app_usage_pct,
|
|
||||||
'account_usage_pct': self.account_usage_pct,
|
# X-App-Usage metrics
|
||||||
'max_usage_pct': max(self.app_usage_pct, self.account_usage_pct),
|
'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(),
|
'is_throttling': self.should_throttle(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def print_stats(self):
|
def print_stats(self):
|
||||||
"""Print current statistics."""
|
"""Print current statistics with all rate limit metrics."""
|
||||||
stats = self.get_stats()
|
stats = self.get_stats()
|
||||||
print("\n" + "="*60)
|
|
||||||
print("RATE LIMITER STATISTICS")
|
output = []
|
||||||
print("="*60)
|
output.append("\n" + "="*70)
|
||||||
print(f"Total Requests: {stats['total_requests']}")
|
output.append("RATE LIMITER STATISTICS")
|
||||||
print(f"Throttled Requests: {stats['throttled_requests']}")
|
output.append("="*70)
|
||||||
print(f"Rate Limit Errors: {stats['rate_limit_errors']}")
|
|
||||||
print(f"App Usage: {stats['app_usage_pct']:.1f}%")
|
# Request stats
|
||||||
print(f"Account Usage: {stats['account_usage_pct']:.1f}%")
|
output.append(f"Total Requests: {stats['total_requests']}")
|
||||||
print(f"Max Usage: {stats['max_usage_pct']:.1f}%")
|
output.append(f"Throttled Requests: {stats['throttled_requests']}")
|
||||||
print(f"Currently Throttled: {stats['is_throttling']}")
|
output.append(f"Rate Limit Errors: {stats['rate_limit_errors']}")
|
||||||
print("="*60 + "\n")
|
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))
|
||||||
|
|||||||
132
src/meta_api_grabber/test_google_ads_accounts.py
Normal file
132
src/meta_api_grabber/test_google_ads_accounts.py
Normal 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()
|
||||||
171
src/meta_api_grabber/test_page_leads.py
Normal file
171
src/meta_api_grabber/test_page_leads.py
Normal 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
156
test_rate_limiter.py
Normal 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
230
uv.lock
generated
@@ -1,6 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.14'",
|
||||||
|
"python_full_version < '3.14'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohappyeyeballs"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.10.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.2.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@@ -387,6 +519,7 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "facebook-business" },
|
{ name = "facebook-business" },
|
||||||
|
{ name = "google-ads" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests-oauthlib" },
|
{ name = "requests-oauthlib" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
@@ -398,6 +531,7 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.17.0" },
|
{ name = "alembic", specifier = ">=1.17.0" },
|
||||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||||
{ name = "facebook-business", specifier = ">=23.0.3" },
|
{ name = "facebook-business", specifier = ">=23.0.3" },
|
||||||
|
{ name = "google-ads", specifier = ">=28.3.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||||
{ name = "requests-oauthlib", specifier = ">=2.0.0" },
|
{ name = "requests-oauthlib", specifier = ">=2.0.0" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.44" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pycountry"
|
name = "pycountry"
|
||||||
version = "24.6.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user