Compare commits
21 Commits
68223f664a
...
view_manag
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f6323715 | |||
| c0f24715f4 | |||
| 7fe9dfe6f6 | |||
| 112755224f | |||
| 4778d2043b | |||
| 10d5bdc518 | |||
| 5ecf58ba6a | |||
| 5002528eae | |||
| 4826c4a744 | |||
| 56e1edb0db | |||
| 45bc817428 | |||
| a1d9a19d04 | |||
|
|
c5fa92c4ec | ||
|
|
a92c5b699f | ||
|
|
5f83ecd7ee | ||
|
|
511f381ff2 | ||
|
|
03ae7ea61a | ||
|
|
987553ef74 | ||
|
|
8f4753ff20 | ||
|
|
3ae4ce0d83 | ||
| e577945e75 |
@@ -1,2 +1,4 @@
|
||||
Uv managed python project that grabs data from the meta api and saves it in a timescaledb database.
|
||||
|
||||
Always use uv run to execute python related stuff
|
||||
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for Meta API Grabber
|
||||
# Production-ready container for scheduled data collection
|
||||
# Dockerfile for View Manager
|
||||
# Production-ready container for database view management
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
@@ -15,26 +15,20 @@ RUN apt-get update && apt-get install -y \
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ ./src/
|
||||
COPY metadata.yaml ./metadata.yaml
|
||||
|
||||
# Install Python dependencies using uv
|
||||
RUN uv pip install --system -e .
|
||||
|
||||
# Copy environment file template (will be overridden by volume mount)
|
||||
# This is just for documentation - actual .env should be mounted
|
||||
COPY .env.example .env.example
|
||||
|
||||
# Create directory for token metadata
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Health check - verify the script can at least import
|
||||
# Health check - verify database connection
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD python -c "from src.meta_api_grabber.scheduled_grabber import ScheduledInsightsGrabber; print('OK')" || exit 1
|
||||
CMD python -c "import asyncio; from meta_api_grabber.database import TimescaleDBClient; asyncio.run(TimescaleDBClient().connect())" || exit 1
|
||||
|
||||
# Run the scheduled grabber
|
||||
CMD ["python", "-m", "src.meta_api_grabber.scheduled_grabber"]
|
||||
# Run the view manager setup
|
||||
CMD ["uv", "run", "view-manager-setup"]
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
# 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. **Stored per account ID** to support multiple 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"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Metrics are tracked separately for each ad account in a dictionary keyed by account ID (e.g., `act_123456789`).
|
||||
|
||||
### 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'
|
||||
)
|
||||
```
|
||||
|
||||
### Updating Usage with Account ID
|
||||
When calling `update_usage()`, you can optionally provide an account ID to track per-account metrics:
|
||||
|
||||
```python
|
||||
# Option 1: Provide account_id explicitly
|
||||
limiter.update_usage(response, account_id='act_123456789')
|
||||
|
||||
# Option 2: Let the limiter try to extract it from the response
|
||||
limiter.update_usage(response) # Will attempt to extract account_id
|
||||
```
|
||||
|
||||
### 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"Regain access in: {stats['estimated_time_to_regain_access']} min")
|
||||
|
||||
# Per-account metrics
|
||||
for account_id, usage in stats['ad_account_usage'].items():
|
||||
print(f"Account {account_id}:")
|
||||
print(f" Usage: {usage['acc_id_util_pct']}%")
|
||||
print(f" Reset in: {usage['reset_time_duration']}s")
|
||||
print(f" API tier: {usage['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/)
|
||||
88
VIEWS_SUMMARY.md
Normal file
88
VIEWS_SUMMARY.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Database Views Summary
|
||||
|
||||
## Overview
|
||||
Updated the public schema with new views to make advertising analytics data from Meta, Google, and combined sources more accessible.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Adapted `explore_schemas.py` Script
|
||||
- Modified to save schema exploration output to `schema_info.txt`
|
||||
- Provides persistent reference for table structures across meta, google, and alpinebits schemas
|
||||
- Usage: `uv run explore_schemas.py`
|
||||
|
||||
### 2. New Views Added
|
||||
|
||||
#### **account_insights_by_country** ✓
|
||||
- Aggregates Meta campaign insights by country to account level
|
||||
- **Columns**: time, account_id, country, impressions, clicks, spend, link_click, landing_page_view, lead
|
||||
- **Purpose**: Analyze Meta advertising performance by geographic region
|
||||
- **Source**: meta.custom_campaign_country
|
||||
|
||||
#### **g_campaign_insights** ✓
|
||||
- Google campaign-level insights with calculated performance metrics
|
||||
- **Columns**: time, campaign_id, campaign_name, clicks, impressions, interactions, cost_micros, cost, conversions, all_conversions, conversions_value, ctr, cpm, cpc, cost_per_conversion
|
||||
- **Purpose**: Unified Google campaign performance view with key metrics (CTR, CPM, CPC, Cost per Conversion)
|
||||
- **Source**: google.campaign_metrics
|
||||
|
||||
#### **unified_account_insights_by_device** ✓
|
||||
- Combines Meta and Google account insights broken down by device type
|
||||
- **Columns**: time, google_account_id, meta_account_id, device, google_impressions, meta_impressions, total_impressions, google_clicks, meta_clicks, total_clicks, google_cost, meta_spend, total_spend, meta_link_clicks, meta_leads
|
||||
- **Purpose**: Compare Meta vs Google performance by device (DESKTOP, MOBILE, TABLET)
|
||||
- **Requires**: account_metadata table for linking Meta and Google accounts
|
||||
|
||||
#### **unified_account_insights_by_gender** ✓
|
||||
- Meta audience insights broken down by gender
|
||||
- **Columns**: time, meta_account_id, gender, impressions, clicks, spend, link_clicks, leads
|
||||
- **Purpose**: Analyze Meta advertising performance by audience gender demographics
|
||||
- **Source**: meta account_insights_by_gender
|
||||
|
||||
### 3. Existing Views (Not Modified)
|
||||
The following views were already present and working correctly:
|
||||
- **campaign_insights** - Base Meta campaign insights
|
||||
- **campaign_insights_by_gender/age/device/country** - Meta campaign breakdowns
|
||||
- **account_insights** - Aggregated Meta account insights
|
||||
- **account_insights_by_gender/age/device/gender_and_age** - Meta account breakdowns
|
||||
- **ads_insights** (materialized) - Individual ad performance
|
||||
- **adset_insights** - Ad set level insights
|
||||
- **g_account_insights** - Google account-level insights
|
||||
- **g_account_insights_device** - Google account insights by device
|
||||
- **unified_account_insights** - Combined Meta+Google account-level view
|
||||
|
||||
## Testing
|
||||
All views have been tested and verified to:
|
||||
- Execute without errors
|
||||
- Return valid data with correct column structures
|
||||
- Support LIMIT queries for performance verification
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```sql
|
||||
-- View Meta performance by country
|
||||
SELECT time, country, SUM(impressions), SUM(spend), SUM(lead)
|
||||
FROM account_insights_by_country
|
||||
WHERE account_id = '1416908162571377'
|
||||
GROUP BY time, country;
|
||||
|
||||
-- View Google campaign metrics
|
||||
SELECT time, campaign_name, clicks, impressions, cpc, cpm
|
||||
FROM g_campaign_insights
|
||||
WHERE ctr > 2.0
|
||||
ORDER BY time DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Compare device performance across Meta and Google
|
||||
SELECT time, device, total_impressions, total_clicks, total_spend
|
||||
FROM unified_account_insights_by_device
|
||||
ORDER BY time DESC;
|
||||
|
||||
-- Analyze Meta audience by gender
|
||||
SELECT time, gender, impressions, clicks, spend, leads
|
||||
FROM unified_account_insights_by_gender
|
||||
WHERE time > CURRENT_DATE - INTERVAL '30 days'
|
||||
ORDER BY time, gender;
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
- Set up automated refreshes for materialized views if needed
|
||||
- Create additional unified views for campaign-level comparisons
|
||||
- Consider indexing frequently queried views for performance optimization
|
||||
@@ -19,22 +19,22 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Meta API Grabber - Scheduled data collection
|
||||
meta-grabber:
|
||||
# View Manager - Setup database schema and views
|
||||
view-manager:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: meta_api_grabber
|
||||
container_name: view_manager
|
||||
environment:
|
||||
# Database connection (connects to timescaledb service)
|
||||
DATABASE_URL: postgresql://meta_user:meta_password@timescaledb:5432/meta_insights
|
||||
env_file:
|
||||
- .env # Must contain META_ACCESS_TOKEN, META_APP_ID, META_APP_SECRET
|
||||
- .env # Optional: for any additional configuration
|
||||
volumes:
|
||||
# Mount .env for token updates (auto-refresh will update the file)
|
||||
- ./.env:/app/.env
|
||||
# Mount token metadata file (preserves token refresh state across restarts)
|
||||
- ./.meta_token.json:/app/.meta_token.json
|
||||
# Mount metadata.yaml for account configuration
|
||||
- ./metadata.yaml:/app/metadata.yaml:ro
|
||||
# Optional: Mount custom db_schema.sql if you want to edit it externally
|
||||
# - ./src/meta_api_grabber/db_schema.sql:/app/src/meta_api_grabber/db_schema.sql:ro
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
|
||||
110
explore_schemas.py
Normal file
110
explore_schemas.py
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to explore available tables and their structures in meta, google, and alpinebits schemas.
|
||||
Saves output to schema_info.txt file.
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL")
|
||||
OUTPUT_FILE = "schema_info.txt"
|
||||
|
||||
async def get_connection():
|
||||
"""Create database connection"""
|
||||
return await asyncpg.connect(DB_URL)
|
||||
|
||||
async def get_schema_info(schema_name, output_file):
|
||||
"""Get all tables and their columns for a schema"""
|
||||
conn = await get_connection()
|
||||
|
||||
header = f"\n{'='*80}\nSCHEMA: {schema_name}\n{'='*80}\n"
|
||||
print(header, end="")
|
||||
output_file.write(header)
|
||||
|
||||
# Get all tables in schema
|
||||
query = """
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1
|
||||
ORDER BY table_name
|
||||
"""
|
||||
tables = await conn.fetch(query, schema_name)
|
||||
|
||||
if not tables:
|
||||
msg = f"No tables found in schema '{schema_name}'\n"
|
||||
print(msg, end="")
|
||||
output_file.write(msg)
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
for row in tables:
|
||||
table_name = row['table_name']
|
||||
table_header = f"\nTable: {table_name}\n" + "-" * 80 + "\n"
|
||||
print(table_header, end="")
|
||||
output_file.write(table_header)
|
||||
|
||||
# Get columns for this table
|
||||
col_query = """
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
columns = await conn.fetch(col_query, schema_name, table_name)
|
||||
|
||||
for col in columns:
|
||||
null_str = "NULL" if col['is_nullable'] == "YES" else "NOT NULL"
|
||||
col_line = f" - {col['column_name']}: {col['data_type']} ({null_str})\n"
|
||||
print(col_line, end="")
|
||||
output_file.write(col_line)
|
||||
|
||||
# Get row count
|
||||
try:
|
||||
count_query = f"SELECT COUNT(*) FROM {schema_name}.{table_name}"
|
||||
count = await conn.fetchval(count_query)
|
||||
count_line = f" Rows: {count}\n"
|
||||
print(count_line, end="")
|
||||
output_file.write(count_line)
|
||||
except Exception as e:
|
||||
count_line = f" Rows: (unable to count)\n"
|
||||
print(count_line, end="")
|
||||
output_file.write(count_line)
|
||||
|
||||
# Show sample data
|
||||
try:
|
||||
sample_query = f"SELECT * FROM {schema_name}.{table_name} LIMIT 1"
|
||||
sample = await conn.fetchrow(sample_query)
|
||||
if sample:
|
||||
sample_line = f" Sample: {dict(sample)}\n"
|
||||
print(sample_line, end="")
|
||||
output_file.write(sample_line)
|
||||
except Exception as e:
|
||||
sample_line = f" Sample: (unable to fetch)\n"
|
||||
print(sample_line, end="")
|
||||
output_file.write(sample_line)
|
||||
|
||||
await conn.close()
|
||||
|
||||
async def main():
|
||||
schemas = ["meta", "google", "alpinebits"]
|
||||
|
||||
with open(OUTPUT_FILE, "w") as output_file:
|
||||
for schema in schemas:
|
||||
try:
|
||||
await get_schema_info(schema, output_file)
|
||||
except Exception as e:
|
||||
error_msg = f"\nError accessing schema '{schema}': {e}\n"
|
||||
print(error_msg, end="")
|
||||
output_file.write(error_msg)
|
||||
|
||||
completion_msg = f"\n{'='*80}\nExploration complete! Output saved to {OUTPUT_FILE}\n"
|
||||
print(completion_msg, end="")
|
||||
output_file.write(completion_msg)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
24
metadata.yaml
Normal file
24
metadata.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Metadata configuration for account mapping
|
||||
# This file stores important metadata that links different accounts and identifiers
|
||||
|
||||
accounts:
|
||||
- label: "Hotel Bemelmans Post"
|
||||
meta_account_id: "238334370765317"
|
||||
google_account_id: "7581209925"
|
||||
alpinebits_hotel_code: "39054_001"
|
||||
|
||||
- label: "Jagdhof-Kaltern"
|
||||
#meta_account_id: "act_987654321"
|
||||
google_account_id: "1951919786"
|
||||
alpinebits_hotel_code: "39052_001"
|
||||
|
||||
- label: "Residence Erika"
|
||||
google_account_id: "6604634947"
|
||||
alpinebits_hotel_code: "39040_001"
|
||||
|
||||
|
||||
# Add more accounts as needed
|
||||
# - label: "Your Account Name"
|
||||
# meta_account_id: "act_xxxxx"
|
||||
# google_account_id: "xxx-xxx-xxxx"
|
||||
# alpinebits_hotel_code: "HOTELxxx"
|
||||
@@ -1,28 +1,23 @@
|
||||
[project]
|
||||
name = "meta-api-grabber"
|
||||
version = "0.1.0"
|
||||
description = "Meta Marketing API data grabber with TimescaleDB storage"
|
||||
description = "View manager for TimescaleDB with metadata handling"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiohttp>=3.13.1",
|
||||
"alembic>=1.17.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"facebook-business>=23.0.3",
|
||||
"google-ads>=28.3.0",
|
||||
"python-dotenv>=1.1.1",
|
||||
"requests-oauthlib>=2.0.0",
|
||||
"sqlalchemy[asyncio]>=2.0.44",
|
||||
"pyyaml>=6.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
meta-auth = "meta_api_grabber.auth:main"
|
||||
meta-scheduled = "meta_api_grabber.scheduled_grabber:main"
|
||||
meta-insights = "meta_api_grabber.insights_grabber:main"
|
||||
meta-test-accounts = "meta_api_grabber.test_ad_accounts:main"
|
||||
meta-test-leads = "meta_api_grabber.test_page_leads:main"
|
||||
meta-token = "meta_api_grabber.token_manager:main"
|
||||
google-ads-test = "meta_api_grabber.test_google_ads_accounts:main"
|
||||
view-manager-setup = "meta_api_grabber.setup_and_wait:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
551
schema_info.txt
Normal file
551
schema_info.txt
Normal file
@@ -0,0 +1,551 @@
|
||||
|
||||
================================================================================
|
||||
SCHEMA: meta
|
||||
================================================================================
|
||||
|
||||
Table: ad_sets
|
||||
--------------------------------------------------------------------------------
|
||||
- id: character varying (NULL)
|
||||
- name: character varying (NULL)
|
||||
- adlabels: jsonb (NULL)
|
||||
- bid_info: jsonb (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- bid_amount: numeric (NULL)
|
||||
- start_time: timestamp with time zone (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- created_time: timestamp with time zone (NULL)
|
||||
- daily_budget: numeric (NULL)
|
||||
- updated_time: timestamp with time zone (NULL)
|
||||
- bid_constraints: jsonb (NULL)
|
||||
- lifetime_budget: numeric (NULL)
|
||||
- promoted_object: jsonb (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 47
|
||||
Sample: {'id': '120207428712700382', 'name': 'Neue Anzeigengruppe für Leads', 'adlabels': None, 'bid_info': None, 'account_id': '1416908162571377', 'bid_amount': None, 'start_time': datetime.datetime(2024, 3, 15, 8, 50, 7, tzinfo=datetime.timezone.utc), 'campaign_id': '120207428712650382', 'created_time': datetime.datetime(2024, 3, 15, 8, 50, 7, tzinfo=datetime.timezone.utc), 'daily_budget': Decimal('1000.000000000'), 'updated_time': datetime.datetime(2025, 1, 4, 6, 42, tzinfo=datetime.timezone.utc), 'bid_constraints': None, 'lifetime_budget': Decimal('0E-9'), 'promoted_object': '{"page_id": "106160371809313"}', '_airbyte_raw_id': 'dccb9c4b-7fa8-4e98-9249-7a359dd86521', '_airbyte_extracted_at': datetime.datetime(2025, 11, 13, 16, 6, 6, 112000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 2, '_airbyte_meta': '{"changes": [], "sync_id": 42}'}
|
||||
|
||||
Table: ads
|
||||
--------------------------------------------------------------------------------
|
||||
- id: character varying (NULL)
|
||||
- name: character varying (NULL)
|
||||
- status: character varying (NULL)
|
||||
- adlabels: jsonb (NULL)
|
||||
- adset_id: character varying (NULL)
|
||||
- bid_info: jsonb (NULL)
|
||||
- bid_type: character varying (NULL)
|
||||
- creative: jsonb (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- bid_amount: bigint (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- created_time: timestamp with time zone (NULL)
|
||||
- source_ad_id: character varying (NULL)
|
||||
- updated_time: timestamp with time zone (NULL)
|
||||
- tracking_specs: jsonb (NULL)
|
||||
- recommendations: jsonb (NULL)
|
||||
- conversion_specs: jsonb (NULL)
|
||||
- effective_status: character varying (NULL)
|
||||
- last_updated_by_app_id: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 163
|
||||
Sample: {'id': '120236221098270382', 'name': 'Vertriebsmitarbeiter_14.11.2025_Grafik_1', 'status': 'ACTIVE', 'adlabels': None, 'adset_id': '120236221098250382', 'bid_info': None, 'bid_type': 'ABSOLUTE_OCPM', 'creative': '{"id": "2042451856579224"}', 'account_id': '1416908162571377', 'bid_amount': None, 'campaign_id': '120219679742650382', 'created_time': datetime.datetime(2025, 11, 14, 9, 30, 29, tzinfo=datetime.timezone.utc), 'source_ad_id': '0', 'updated_time': datetime.datetime(2025, 11, 14, 9, 45, 27, tzinfo=datetime.timezone.utc), 'tracking_specs': '[{"fb_pixel": ["1165111234632775"], "action.type": ["offsite_conversion"]}, {"action.type": ["onsite_conversion"], "conversion_id": ["9883202595118306"]}, {"action.type": ["onsite_conversion"]}, {"page": ["106160371809313"], "post": ["860615083140579"], "action.type": ["post_engagement"]}, {"page": ["106160371809313"], "post": ["860615083140579"], "action.type": ["post_interaction_gross"]}, {"post": ["860615083140579"], "post.wall": ["106160371809313"], "action.type": ["link_click"]}]', 'recommendations': None, 'conversion_specs': '[{"action.type": ["offsite_conversion"], "conversion_id": ["25218548091171302"]}]', 'effective_status': 'ACTIVE', 'last_updated_by_app_id': '119211728144504', '_airbyte_raw_id': '826ad572-f17f-4f94-a682-fe633da5092d', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 6, 20, 979000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 1, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: ads_insights
|
||||
--------------------------------------------------------------------------------
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- ad_id: character varying (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- ad_name: character varying (NULL)
|
||||
- adset_id: character varying (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- objective: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- adset_name: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- conversions: jsonb (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- account_name: character varying (NULL)
|
||||
- created_time: date (NULL)
|
||||
- action_values: jsonb (NULL)
|
||||
- account_currency: character varying (NULL)
|
||||
- ad_click_actions: jsonb (NULL)
|
||||
- conversion_values: jsonb (NULL)
|
||||
- optimization_goal: character varying (NULL)
|
||||
- cost_per_conversion: jsonb (NULL)
|
||||
- ad_impression_actions: jsonb (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 6654
|
||||
Sample: {'cpc': Decimal('1.190000000'), 'cpm': Decimal('10.967742000'), 'cpp': Decimal('13.678161000'), 'ctr': Decimal('0.921659000'), 'ad_id': '120226947319320382', 'reach': 174, 'spend': Decimal('2.380000000'), 'clicks': 2, 'actions': '[{"value": 15, "1d_view": 15, "7d_view": 15, "28d_view": 15, "action_type": "page_engagement", "action_target_id": "0", "action_destination": "unknown"}, {"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "page_engagement", "action_target_id": "1240005757657489", "action_destination": "Content Creator (m/w/d) gesucht – Jetzt bewerben!"}, {"value": 15, "1d_view": 15, "7d_view": 15, "28d_view": 15, "action_type": "post_engagement", "action_target_id": "0", "action_destination": "unknown"}, {"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "post_engagement", "action_target_id": "1240005757657489", "action_destination": "Content Creator (m/w/d) gesucht – Jetzt bewerben!"}, {"value": 15, "1d_view": 15, "7d_view": 15, "28d_view": 15, "action_type": "video_view", "action_target_id": "0", "action_destination": "unknown"}, {"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "video_view", "action_target_id": "1240005757657489", "action_destination": "Content Creator (m/w/d) gesucht – Jetzt bewerben!"}]', 'ad_name': 'Content_Creator_11.06.25', 'adset_id': '120226947319300382', 'date_stop': datetime.date(2025, 11, 14), 'frequency': Decimal('1.247126000'), 'objective': 'OUTCOME_LEADS', 'account_id': '1416908162571377', 'adset_name': 'Content Creator Bewerber 25_InstantForm_DE', 'date_start': datetime.date(2025, 11, 14), 'campaign_id': '120219679742650382', 'conversions': None, 'impressions': 217, 'account_name': '99tales', 'created_time': datetime.date(2025, 6, 11), 'action_values': None, 'account_currency': 'EUR', 'ad_click_actions': None, 'conversion_values': None, 'optimization_goal': 'LEAD_GENERATION', 'cost_per_conversion': None, 'ad_impression_actions': None, '_airbyte_raw_id': '1603d437-0177-4fb7-9f25-0a1f2e91a548', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 8, 38, 501000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 3, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: ads_insights_age_and_gender
|
||||
--------------------------------------------------------------------------------
|
||||
- age: character varying (NULL)
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ad_id: character varying (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- gender: character varying (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- adset_id: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- conversions: jsonb (NULL)
|
||||
- account_name: character varying (NULL)
|
||||
- created_time: date (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 5962
|
||||
Sample: {'age': '45-54', 'cpc': None, 'cpm': Decimal('3.750000000'), 'cpp': Decimal('4.285714000'), 'ad_id': '120236395246040196', 'clicks': 0, 'gender': 'male', 'actions': None, 'adset_id': '120223804684640196', 'account_id': '238334370765317', 'date_start': datetime.date(2025, 10, 5), 'campaign_id': '120223804684630196', 'conversions': None, 'account_name': 'Hotel Bemelmans Post', 'created_time': datetime.date(2025, 8, 26), '_airbyte_raw_id': 'fc0e034f-5938-4fe7-9364-d36743807245', '_airbyte_extracted_at': datetime.datetime(2025, 11, 3, 15, 47, 3, 587000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 0, '_airbyte_meta': '{"changes": [], "sync_id": 1}'}
|
||||
|
||||
Table: ads_insights_country
|
||||
--------------------------------------------------------------------------------
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- ad_id: character varying (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- country: character varying (NULL)
|
||||
- objective: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- conversions: jsonb (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- created_time: date (NULL)
|
||||
- account_currency: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 1701
|
||||
Sample: {'cpc': Decimal('0.459545000'), 'cpm': Decimal('12.086073000'), 'cpp': Decimal('15.033457000'), 'ctr': Decimal('2.630006000'), 'ad_id': '120224889911030196', 'clicks': 44, 'country': 'IT', 'objective': 'OUTCOME_LEADS', 'account_id': '238334370765317', 'date_start': datetime.date(2025, 10, 5), 'conversions': None, 'impressions': 1673, 'created_time': datetime.date(2025, 4, 10), 'account_currency': 'EUR', '_airbyte_raw_id': '37a9805d-66c5-4288-ac51-04ec995a1ba6', '_airbyte_extracted_at': datetime.datetime(2025, 11, 3, 15, 51, 24, 448000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 0, '_airbyte_meta': '{"changes": [], "sync_id": 1}'}
|
||||
|
||||
Table: campaigns
|
||||
--------------------------------------------------------------------------------
|
||||
- id: character varying (NULL)
|
||||
- name: character varying (NULL)
|
||||
- status: character varying (NULL)
|
||||
- adlabels: jsonb (NULL)
|
||||
- objective: character varying (NULL)
|
||||
- spend_cap: numeric (NULL)
|
||||
- stop_time: timestamp with time zone (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- start_time: timestamp with time zone (NULL)
|
||||
- buying_type: character varying (NULL)
|
||||
- issues_info: jsonb (NULL)
|
||||
- bid_strategy: character varying (NULL)
|
||||
- created_time: timestamp with time zone (NULL)
|
||||
- daily_budget: numeric (NULL)
|
||||
- updated_time: timestamp with time zone (NULL)
|
||||
- lifetime_budget: numeric (NULL)
|
||||
- budget_remaining: numeric (NULL)
|
||||
- effective_status: character varying (NULL)
|
||||
- boosted_object_id: character varying (NULL)
|
||||
- configured_status: character varying (NULL)
|
||||
- source_campaign_id: numeric (NULL)
|
||||
- special_ad_category: character varying (NULL)
|
||||
- smart_promotion_type: character varying (NULL)
|
||||
- budget_rebalance_flag: boolean (NULL)
|
||||
- special_ad_category_country: jsonb (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 25
|
||||
Sample: {'id': '120207428712650382', 'name': 'Bewerber_99tales', 'status': 'PAUSED', 'adlabels': None, 'objective': 'OUTCOME_LEADS', 'spend_cap': None, 'stop_time': None, 'account_id': '1416908162571377', 'start_time': datetime.datetime(2024, 3, 15, 8, 50, 7, tzinfo=datetime.timezone.utc), 'buying_type': 'AUCTION', 'issues_info': None, 'bid_strategy': None, 'created_time': datetime.datetime(2024, 3, 15, 8, 50, 6, tzinfo=datetime.timezone.utc), 'daily_budget': None, 'updated_time': datetime.datetime(2025, 10, 10, 5, 42, 18, tzinfo=datetime.timezone.utc), 'lifetime_budget': None, 'budget_remaining': Decimal('0E-9'), 'effective_status': 'PAUSED', 'boosted_object_id': None, 'configured_status': 'PAUSED', 'source_campaign_id': Decimal('0E-9'), 'special_ad_category': 'EMPLOYMENT', 'smart_promotion_type': 'GUIDED_CREATION', 'budget_rebalance_flag': False, 'special_ad_category_country': '["IT"]', '_airbyte_raw_id': 'd94ca411-6b5a-4382-871c-681de8f35440', '_airbyte_extracted_at': datetime.datetime(2025, 11, 13, 17, 24, 46, 639000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 12, '_airbyte_meta': '{"changes": [], "sync_id": 42}'}
|
||||
|
||||
Table: custom_campaign_country
|
||||
--------------------------------------------------------------------------------
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- country: character varying (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- objective: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- created_time: date (NULL)
|
||||
- social_spend: numeric (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 2038
|
||||
Sample: {'cpc': Decimal('0.908257000'), 'cpm': Decimal('7.444729000'), 'cpp': Decimal('9.872357000'), 'ctr': Decimal('0.819672000'), 'reach': 92, 'spend': Decimal('0.908257000'), 'clicks': 1, 'actions': '[{"value": 18, "1d_view": 17, "7d_view": 17, "1d_click": 1, "28d_view": 17, "7d_click": 1, "28d_click": 1, "action_type": "page_engagement"}, {"value": 18, "1d_view": 17, "7d_view": 17, "1d_click": 1, "28d_view": 17, "7d_click": 1, "28d_click": 1, "action_type": "post_engagement"}, {"value": 1, "1d_click": 1, "7d_click": 1, "28d_click": 1, "action_type": "link_click"}, {"value": 17, "1d_view": 17, "7d_view": 17, "28d_view": 17, "action_type": "video_view"}]', 'country': 'AT', 'date_stop': datetime.date(2025, 11, 14), 'frequency': Decimal('1.326087000'), 'objective': 'OUTCOME_LEADS', 'account_id': '1416908162571377', 'date_start': datetime.date(2025, 11, 14), 'campaign_id': '120235573403300382', 'impressions': 122, 'created_time': datetime.date(2025, 10, 31), 'social_spend': None, '_airbyte_raw_id': '31ca2e7a-6757-4c8e-96e8-6e3768c86315', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 14, 21, 341000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 1, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: custom_campaign_device
|
||||
--------------------------------------------------------------------------------
|
||||
- ad_id: character varying (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- campaign_name: character varying (NULL)
|
||||
- device_platform: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 1291
|
||||
Sample: {'ad_id': None, 'reach': 5, 'spend': Decimal('0.111560000'), 'clicks': 0, 'actions': None, 'date_stop': datetime.date(2025, 11, 14), 'frequency': Decimal('1.000000000'), 'account_id': '1416908162571377', 'date_start': datetime.date(2025, 11, 14), 'campaign_id': '120235573403300382', 'impressions': 5, 'campaign_name': 'MairLorenz_Cold_Traffic_Conversions_InstantForm', 'device_platform': 'desktop', '_airbyte_raw_id': 'a9231688-5cde-4f02-a84d-99759e7ea3d6', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 18, 56, 210000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 0, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: custom_campaign_gender
|
||||
--------------------------------------------------------------------------------
|
||||
- age: character varying (NULL)
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- gender: character varying (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- conversions: jsonb (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- created_time: date (NULL)
|
||||
- updated_time: date (NULL)
|
||||
- campaign_name: character varying (NULL)
|
||||
- purchase_roas: jsonb (NULL)
|
||||
- account_currency: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 8554
|
||||
Sample: {'age': '18-24', 'cpc': None, 'cpm': Decimal('12.060708000'), 'cpp': Decimal('13.916202000'), 'ctr': Decimal('0E-9'), 'reach': 13, 'spend': Decimal('0.180911000'), 'clicks': 0, 'gender': 'female', 'actions': '[{"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "page_engagement"}, {"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "post_engagement"}, {"value": 3, "1d_view": 3, "7d_view": 3, "28d_view": 3, "action_type": "video_view"}]', 'date_stop': datetime.date(2025, 11, 14), 'frequency': Decimal('1.153846000'), 'account_id': '1416908162571377', 'date_start': datetime.date(2025, 11, 14), 'campaign_id': '120218699205090382', 'conversions': None, 'impressions': 15, 'created_time': datetime.date(2025, 3, 15), 'updated_time': datetime.date(2025, 10, 10), 'campaign_name': '15_03_25_Adverto_Hotelmarketing', 'purchase_roas': None, 'account_currency': 'EUR', '_airbyte_raw_id': '4642fee0-6c01-4297-b4fd-48acddf44062', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 16, 22, 159000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 3, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: custom_campaign_gender_ab_soft_reset
|
||||
--------------------------------------------------------------------------------
|
||||
- age: character varying (NULL)
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- gender: character varying (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- conversions: jsonb (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- created_time: date (NULL)
|
||||
- updated_time: date (NULL)
|
||||
- campaign_name: character varying (NULL)
|
||||
- purchase_roas: jsonb (NULL)
|
||||
- account_currency: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 1105
|
||||
Sample: {'age': None, 'cpc': Decimal('1.044085000'), 'cpm': Decimal('22.273805000'), 'cpp': Decimal('24.281038000'), 'ctr': Decimal('2.133333000'), 'reach': 344, 'spend': Decimal('8.352677000'), 'clicks': 8, 'gender': 'female', 'actions': '[{"value": 7, "1d_click": 7, "7d_click": 7, "28d_click": 7, "action_type": "page_engagement"}, {"value": 2, "1d_click": 2, "7d_click": 2, "28d_click": 2, "action_type": "landing_page_view"}, {"value": 2, "1d_click": 2, "7d_click": 2, "28d_click": 2, "action_type": "omni_landing_page_view"}, {"value": 7, "1d_click": 7, "7d_click": 7, "28d_click": 7, "action_type": "post_engagement"}, {"value": 7, "1d_click": 7, "7d_click": 7, "28d_click": 7, "action_type": "link_click"}]', 'date_stop': datetime.date(2025, 11, 11), 'frequency': Decimal('1.090116000'), 'account_id': '238334370765317', 'date_start': datetime.date(2025, 11, 11), 'campaign_id': '120223804684630196', 'conversions': None, 'impressions': 375, 'created_time': datetime.date(2025, 4, 4), 'updated_time': datetime.date(2025, 5, 23), 'campaign_name': 'Conversions_Hotel_Bemelmans_DEU', 'purchase_roas': None, 'account_currency': 'EUR', '_airbyte_raw_id': '37862ffc-80d4-4c32-bb17-85656877ab99', '_airbyte_extracted_at': datetime.datetime(2025, 11, 11, 16, 27, 40, 468000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 1, '_airbyte_meta': '{"changes": [], "sync_id": 29}'}
|
||||
|
||||
Table: custom_conversions
|
||||
--------------------------------------------------------------------------------
|
||||
- id: character varying (NULL)
|
||||
- name: character varying (NULL)
|
||||
- rule: character varying (NULL)
|
||||
- business: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- description: character varying (NULL)
|
||||
- is_archived: boolean (NULL)
|
||||
- data_sources: jsonb (NULL)
|
||||
- creation_time: timestamp with time zone (NULL)
|
||||
- is_unavailable: boolean (NULL)
|
||||
- retention_days: numeric (NULL)
|
||||
- last_fired_time: timestamp with time zone (NULL)
|
||||
- first_fired_time: timestamp with time zone (NULL)
|
||||
- custom_event_type: character varying (NULL)
|
||||
- event_source_type: character varying (NULL)
|
||||
- default_conversion_value: numeric (NULL)
|
||||
- offline_conversion_data_set: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 12
|
||||
Sample: {'id': '1049186899714335', 'name': 'Purchase', 'rule': '{"and":[{"event":{"eq":"PageView"}},{"or":[{"Event Parameters":{"eq":"purchase"}}]}]}', 'business': None, 'account_id': '277560745', 'description': None, 'is_archived': True, 'data_sources': '[{"id": "7753250311370403", "name": "Spitalerhof Facebook Pixel", "source_type": "PIXEL"}]', 'creation_time': datetime.datetime(2024, 1, 25, 10, 21, 3, tzinfo=datetime.timezone.utc), 'is_unavailable': False, 'retention_days': Decimal('0E-9'), 'last_fired_time': None, 'first_fired_time': None, 'custom_event_type': 'OTHER', 'event_source_type': 'pixel', 'default_conversion_value': Decimal('0E-9'), 'offline_conversion_data_set': None, '_airbyte_raw_id': 'e9adb670-8ebb-441e-b3dc-5163ac5922ec', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 9, 25, 869000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 41, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
Table: customcampaign_insights
|
||||
--------------------------------------------------------------------------------
|
||||
- cpc: numeric (NULL)
|
||||
- cpm: numeric (NULL)
|
||||
- cpp: numeric (NULL)
|
||||
- ctr: numeric (NULL)
|
||||
- ad_id: character varying (NULL)
|
||||
- reach: bigint (NULL)
|
||||
- spend: numeric (NULL)
|
||||
- clicks: bigint (NULL)
|
||||
- actions: jsonb (NULL)
|
||||
- date_stop: date (NULL)
|
||||
- frequency: numeric (NULL)
|
||||
- objective: character varying (NULL)
|
||||
- account_id: character varying (NULL)
|
||||
- date_start: date (NULL)
|
||||
- campaign_id: character varying (NULL)
|
||||
- impressions: bigint (NULL)
|
||||
- created_time: date (NULL)
|
||||
- campaign_name: character varying (NULL)
|
||||
- purchase_roas: jsonb (NULL)
|
||||
- optimization_goal: character varying (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 2328
|
||||
Sample: {'cpc': Decimal('0.565714000'), 'cpm': Decimal('13.846154000'), 'cpp': Decimal('17.217391000'), 'ctr': Decimal('2.447552000'), 'ad_id': None, 'reach': 690, 'spend': Decimal('11.880000000'), 'clicks': 21, 'actions': '[{"value": 3, "1d_click": 3, "7d_click": 3, "28d_click": 3, "action_type": "offsite_complete_registration_add_meta_leads"}, {"value": 174, "1d_view": 161, "7d_view": 161, "1d_click": 13, "28d_view": 161, "7d_click": 13, "28d_click": 13, "action_type": "page_engagement"}, {"value": 174, "1d_view": 161, "7d_view": 161, "1d_click": 13, "28d_view": 161, "7d_click": 13, "28d_click": 13, "action_type": "post_engagement"}, {"value": 3, "1d_click": 3, "7d_click": 3, "28d_click": 3, "action_type": "offsite_content_view_add_meta_leads"}, {"value": 3, "1d_click": 3, "7d_click": 3, "28d_click": 3, "action_type": "lead"}, {"value": 3, "1d_click": 3, "7d_click": 3, "28d_click": 3, "action_type": "offsite_search_add_meta_leads"}, {"value": 11, "1d_click": 11, "7d_click": 11, "28d_click": 11, "action_type": "link_click"}, {"value": 3, "1d_click": 3, "7d_click": 3, "28d_click": 3, "action_type": "onsite_conversion.lead_grouped"}, {"value": 163, "1d_view": 161, "7d_view": 161, "1d_click": 2, "28d_view": 161, "7d_click": 2, "28d_click": 2, "action_type": "video_view"}]', 'date_stop': datetime.date(2025, 11, 14), 'frequency': Decimal('1.243478000'), 'objective': 'OUTCOME_LEADS', 'account_id': '1416908162571377', 'date_start': datetime.date(2025, 11, 14), 'campaign_id': '120218699205090382', 'impressions': 858, 'created_time': datetime.date(2025, 3, 15), 'campaign_name': '15_03_25_Adverto_Hotelmarketing', 'purchase_roas': None, 'optimization_goal': 'Unknown Optimization Goal', '_airbyte_raw_id': '4ad10a16-dd49-4599-bd7c-65a349edbd4c', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 16, 11, 34, 572000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 4, '_airbyte_meta': '{"changes": [], "sync_id": 44}'}
|
||||
|
||||
================================================================================
|
||||
SCHEMA: google
|
||||
================================================================================
|
||||
|
||||
Table: account_performance_report
|
||||
--------------------------------------------------------------------------------
|
||||
- customer_id: bigint (NULL)
|
||||
- metrics_ctr: numeric (NULL)
|
||||
- segments_date: date (NULL)
|
||||
- metrics_clicks: bigint (NULL)
|
||||
- segments_device: character varying (NULL)
|
||||
- customer_manager: boolean (NULL)
|
||||
- customer_time_zone: character varying (NULL)
|
||||
- metrics_average_cpc: numeric (NULL)
|
||||
- metrics_average_cpe: numeric (NULL)
|
||||
- metrics_average_cpm: numeric (NULL)
|
||||
- metrics_average_cpv: numeric (NULL)
|
||||
- metrics_conversions: numeric (NULL)
|
||||
- metrics_cost_micros: bigint (NULL)
|
||||
- metrics_engagements: bigint (NULL)
|
||||
- metrics_impressions: bigint (NULL)
|
||||
- metrics_video_views: bigint (NULL)
|
||||
- metrics_average_cost: numeric (NULL)
|
||||
- metrics_interactions: bigint (NULL)
|
||||
- segments_day_of_week: character varying (NULL)
|
||||
- customer_test_account: boolean (NULL)
|
||||
- customer_currency_code: character varying (NULL)
|
||||
- metrics_active_view_cpm: numeric (NULL)
|
||||
- metrics_active_view_ctr: numeric (NULL)
|
||||
- metrics_all_conversions: numeric (NULL)
|
||||
- metrics_engagement_rate: numeric (NULL)
|
||||
- metrics_video_view_rate: numeric (NULL)
|
||||
- metrics_interaction_rate: numeric (NULL)
|
||||
- segments_ad_network_type: character varying (NULL)
|
||||
- customer_descriptive_name: character varying (NULL)
|
||||
- metrics_conversions_value: numeric (NULL)
|
||||
- metrics_cost_per_conversion: numeric (NULL)
|
||||
- metrics_value_per_conversion: numeric (NULL)
|
||||
- customer_auto_tagging_enabled: boolean (NULL)
|
||||
- metrics_all_conversions_value: numeric (NULL)
|
||||
- metrics_active_view_impressions: bigint (NULL)
|
||||
- metrics_active_view_viewability: numeric (NULL)
|
||||
- metrics_interaction_event_types: jsonb (NULL)
|
||||
- metrics_search_impression_share: numeric (NULL)
|
||||
- metrics_content_impression_share: numeric (NULL)
|
||||
- metrics_cost_per_all_conversions: numeric (NULL)
|
||||
- metrics_cross_device_conversions: numeric (NULL)
|
||||
- metrics_view_through_conversions: bigint (NULL)
|
||||
- metrics_active_view_measurability: numeric (NULL)
|
||||
- metrics_value_per_all_conversions: numeric (NULL)
|
||||
- metrics_search_rank_lost_impression_share: numeric (NULL)
|
||||
- metrics_active_view_measurable_cost_micros: bigint (NULL)
|
||||
- metrics_active_view_measurable_impressions: bigint (NULL)
|
||||
- metrics_content_rank_lost_impression_share: numeric (NULL)
|
||||
- metrics_conversions_from_interactions_rate: numeric (NULL)
|
||||
- metrics_search_budget_lost_impression_share: numeric (NULL)
|
||||
- metrics_search_exact_match_impression_share: numeric (NULL)
|
||||
- metrics_content_budget_lost_impression_share: numeric (NULL)
|
||||
- metrics_all_conversions_from_interactions_rate: numeric (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 543
|
||||
Sample: {'customer_id': 7581209925, 'metrics_ctr': Decimal('0.083333333'), 'segments_date': datetime.date(2025, 11, 14), 'metrics_clicks': 1, 'segments_device': 'DESKTOP', 'customer_manager': False, 'customer_time_zone': 'Europe/Rome', 'metrics_average_cpc': Decimal('10000.000000000'), 'metrics_average_cpe': None, 'metrics_average_cpm': Decimal('833333.333333333'), 'metrics_average_cpv': None, 'metrics_conversions': Decimal('0E-9'), 'metrics_cost_micros': 10000, 'metrics_engagements': 0, 'metrics_impressions': 12, 'metrics_video_views': 0, 'metrics_average_cost': Decimal('10000.000000000'), 'metrics_interactions': 1, 'segments_day_of_week': 'FRIDAY', 'customer_test_account': False, 'customer_currency_code': 'EUR', 'metrics_active_view_cpm': None, 'metrics_active_view_ctr': None, 'metrics_all_conversions': Decimal('0E-9'), 'metrics_engagement_rate': None, 'metrics_video_view_rate': None, 'metrics_interaction_rate': Decimal('0.083333333'), 'segments_ad_network_type': 'MIXED', 'customer_descriptive_name': 'Hotel Bemelmans Post', 'metrics_conversions_value': Decimal('0E-9'), 'metrics_cost_per_conversion': None, 'metrics_value_per_conversion': None, 'customer_auto_tagging_enabled': True, 'metrics_all_conversions_value': Decimal('0E-9'), 'metrics_active_view_impressions': 0, 'metrics_active_view_viewability': None, 'metrics_interaction_event_types': '["2"]', 'metrics_search_impression_share': None, 'metrics_content_impression_share': None, 'metrics_cost_per_all_conversions': None, 'metrics_cross_device_conversions': Decimal('0E-9'), 'metrics_view_through_conversions': 0, 'metrics_active_view_measurability': Decimal('0E-9'), 'metrics_value_per_all_conversions': None, 'metrics_search_rank_lost_impression_share': None, 'metrics_active_view_measurable_cost_micros': 0, 'metrics_active_view_measurable_impressions': 0, 'metrics_content_rank_lost_impression_share': None, 'metrics_conversions_from_interactions_rate': Decimal('0E-9'), 'metrics_search_budget_lost_impression_share': None, 'metrics_search_exact_match_impression_share': None, 'metrics_content_budget_lost_impression_share': None, 'metrics_all_conversions_from_interactions_rate': Decimal('0E-9'), '_airbyte_raw_id': '2ed0b149-adef-4b9d-a7f8-2276ec48d9ee', '_airbyte_extracted_at': datetime.datetime(2025, 11, 14, 19, 8, 43, 233000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 1, '_airbyte_meta': '{"changes": [], "sync_id": 45}'}
|
||||
|
||||
Table: campaign_metrics
|
||||
--------------------------------------------------------------------------------
|
||||
- campaign_id: bigint (NULL)
|
||||
- campaign_name: character varying (NULL)
|
||||
- segments_date: date (NULL)
|
||||
- metrics_clicks: bigint (NULL)
|
||||
- metrics_orders: numeric (NULL)
|
||||
- campaign_status: character varying (NULL)
|
||||
- campaign_start_date: character varying (NULL)
|
||||
- metrics_conversions: numeric (NULL)
|
||||
- metrics_cost_micros: bigint (NULL)
|
||||
- metrics_impressions: bigint (NULL)
|
||||
- metrics_interactions: bigint (NULL)
|
||||
- metrics_all_conversions: numeric (NULL)
|
||||
- campaign_campaign_budget: character varying (NULL)
|
||||
- metrics_conversions_value: numeric (NULL)
|
||||
- campaign_optimization_score: numeric (NULL)
|
||||
- _airbyte_raw_id: character varying (NOT NULL)
|
||||
- _airbyte_extracted_at: timestamp with time zone (NOT NULL)
|
||||
- _airbyte_generation_id: bigint (NULL)
|
||||
- _airbyte_meta: jsonb (NOT NULL)
|
||||
Rows: 510
|
||||
Sample: {'campaign_id': 183901432, 'campaign_name': 'WW_DE_Brand', 'segments_date': datetime.date(2025, 10, 27), 'metrics_clicks': 8, 'metrics_orders': Decimal('0E-9'), 'campaign_status': 'ENABLED', 'campaign_start_date': '2015-01-25', 'metrics_conversions': Decimal('0E-9'), 'metrics_cost_micros': 9660000, 'metrics_impressions': 60, 'metrics_interactions': 8, 'metrics_all_conversions': Decimal('0E-9'), 'campaign_campaign_budget': 'customers/7581209925/campaignBudgets/173136112', 'metrics_conversions_value': Decimal('0E-9'), 'campaign_optimization_score': Decimal('0.474560674'), '_airbyte_raw_id': 'b992b3dd-07cb-4ef6-ad60-0a27454de75c', '_airbyte_extracted_at': datetime.datetime(2025, 11, 11, 10, 30, 54, 31000, tzinfo=datetime.timezone.utc), '_airbyte_generation_id': 1, '_airbyte_meta': '{"changes": [], "sync_id": 26}'}
|
||||
|
||||
================================================================================
|
||||
SCHEMA: alpinebits
|
||||
================================================================================
|
||||
|
||||
Table: acked_requests
|
||||
--------------------------------------------------------------------------------
|
||||
- id: integer (NOT NULL)
|
||||
- client_id: character varying (NULL)
|
||||
- unique_id: character varying (NULL)
|
||||
- timestamp: timestamp with time zone (NULL)
|
||||
- username: character varying (NULL)
|
||||
Rows: 814
|
||||
Sample: {'id': 1, 'client_id': 'ASA HOTEL 25.10 @', 'unique_id': '666247dc-9d5a-4eb7-87a7-677bf646', 'timestamp': datetime.datetime(2025, 10, 8, 8, 55, 44, 149632, tzinfo=datetime.timezone.utc), 'username': None}
|
||||
|
||||
Table: conversions
|
||||
--------------------------------------------------------------------------------
|
||||
- id: integer (NOT NULL)
|
||||
- reservation_id: integer (NULL)
|
||||
- customer_id: integer (NULL)
|
||||
- hashed_customer_id: integer (NULL)
|
||||
- hotel_id: character varying (NULL)
|
||||
- pms_reservation_id: character varying (NULL)
|
||||
- reservation_number: character varying (NULL)
|
||||
- reservation_date: date (NULL)
|
||||
- creation_time: timestamp with time zone (NULL)
|
||||
- reservation_type: character varying (NULL)
|
||||
- booking_channel: character varying (NULL)
|
||||
- advertising_medium: character varying (NULL)
|
||||
- advertising_partner: character varying (NULL)
|
||||
- advertising_campagne: character varying (NULL)
|
||||
- arrival_date: date (NULL)
|
||||
- departure_date: date (NULL)
|
||||
- room_status: character varying (NULL)
|
||||
- room_type: character varying (NULL)
|
||||
- room_number: character varying (NULL)
|
||||
- num_adults: integer (NULL)
|
||||
- rate_plan_code: character varying (NULL)
|
||||
- sale_date: date (NULL)
|
||||
- revenue_total: character varying (NULL)
|
||||
- revenue_logis: character varying (NULL)
|
||||
- revenue_board: character varying (NULL)
|
||||
- revenue_fb: character varying (NULL)
|
||||
- revenue_spa: character varying (NULL)
|
||||
- revenue_other: character varying (NULL)
|
||||
- created_at: timestamp with time zone (NULL)
|
||||
Rows: 0
|
||||
|
||||
Table: customers
|
||||
--------------------------------------------------------------------------------
|
||||
- id: integer (NOT NULL)
|
||||
- given_name: character varying (NULL)
|
||||
- contact_id: character varying (NULL)
|
||||
- surname: character varying (NULL)
|
||||
- name_prefix: character varying (NULL)
|
||||
- email_address: character varying (NULL)
|
||||
- phone: character varying (NULL)
|
||||
- email_newsletter: boolean (NULL)
|
||||
- address_line: character varying (NULL)
|
||||
- city_name: character varying (NULL)
|
||||
- postal_code: character varying (NULL)
|
||||
- country_code: character varying (NULL)
|
||||
- gender: character varying (NULL)
|
||||
- birth_date: character varying (NULL)
|
||||
- language: character varying (NULL)
|
||||
- address_catalog: boolean (NULL)
|
||||
- name_title: character varying (NULL)
|
||||
Rows: 487
|
||||
Sample: {'id': 1, 'given_name': 'Adriana', 'contact_id': 'd2e59109-a911-4f8f-8339-d9b119a29e56', 'surname': 'Balasa', 'name_prefix': 'Familie', 'email_address': 'adriana.balasa@yahoo.com', 'phone': '+4917645952405', 'email_newsletter': False, 'address_line': None, 'city_name': None, 'postal_code': None, 'country_code': None, 'gender': None, 'birth_date': None, 'language': 'de', 'address_catalog': False, 'name_title': None}
|
||||
|
||||
Table: hashed_customers
|
||||
--------------------------------------------------------------------------------
|
||||
- id: integer (NOT NULL)
|
||||
- customer_id: integer (NOT NULL)
|
||||
- contact_id: character varying (NULL)
|
||||
- hashed_email: character varying (NULL)
|
||||
- hashed_phone: character varying (NULL)
|
||||
- hashed_given_name: character varying (NULL)
|
||||
- hashed_surname: character varying (NULL)
|
||||
- hashed_city: character varying (NULL)
|
||||
- hashed_postal_code: character varying (NULL)
|
||||
- hashed_country_code: character varying (NULL)
|
||||
- hashed_gender: character varying (NULL)
|
||||
- hashed_birth_date: character varying (NULL)
|
||||
- created_at: timestamp with time zone (NULL)
|
||||
Rows: 487
|
||||
Sample: {'id': 387, 'customer_id': 196, 'contact_id': 'e7718c11-b5b1-43bb-a74e-4c0a2edf7938', 'hashed_email': 'b56472d4360348ef2aa9df7479d9adcb031aa83291c9b6d93eaa9c1b38ad9522', 'hashed_phone': '29cbb04171b6cf2ad9f49a01904d94bf1e4e5d5f41460b5e085b9d832376918a', 'hashed_given_name': 'c49b876c05fd376922883ceaf5b7e7871ae4ae9f24e79958f3fc70efe114627d', 'hashed_surname': 'fb18245487ca9ab219a007c65da495254f9cf8b3a89a2341e1d9a9b2a1e26ba4', 'hashed_city': None, 'hashed_postal_code': None, 'hashed_country_code': None, 'hashed_gender': None, 'hashed_birth_date': None, 'created_at': datetime.datetime(2025, 10, 20, 15, 51, 14, 783241, tzinfo=datetime.timezone.utc)}
|
||||
|
||||
Table: reservations
|
||||
--------------------------------------------------------------------------------
|
||||
- id: integer (NOT NULL)
|
||||
- customer_id: integer (NULL)
|
||||
- unique_id: character varying (NULL)
|
||||
- md5_unique_id: character varying (NULL)
|
||||
- start_date: date (NULL)
|
||||
- end_date: date (NULL)
|
||||
- num_adults: integer (NULL)
|
||||
- num_children: integer (NULL)
|
||||
- children_ages: character varying (NULL)
|
||||
- offer: character varying (NULL)
|
||||
- created_at: timestamp with time zone (NULL)
|
||||
- utm_source: character varying (NULL)
|
||||
- utm_medium: character varying (NULL)
|
||||
- utm_campaign: character varying (NULL)
|
||||
- utm_term: character varying (NULL)
|
||||
- utm_content: character varying (NULL)
|
||||
- user_comment: character varying (NULL)
|
||||
- fbclid: character varying (NULL)
|
||||
- gclid: character varying (NULL)
|
||||
- hotel_code: character varying (NULL)
|
||||
- hotel_name: character varying (NULL)
|
||||
- room_type_code: character varying (NULL)
|
||||
- room_classification_code: character varying (NULL)
|
||||
- room_type: character varying (NULL)
|
||||
- meta_account_id: character varying (NULL)
|
||||
- google_account_id: character varying (NULL)
|
||||
Rows: 517
|
||||
Sample: {'id': 1, 'customer_id': 1, 'unique_id': '5abe9219-37f6-415f-97ff-96846313f16c', 'md5_unique_id': '161e4c2752efd919e8b03a1178bfe61c', 'start_date': datetime.date(2025, 12, 26), 'end_date': datetime.date(2025, 9, 28), 'num_adults': 2, 'num_children': 2, 'children_ages': '9,16', 'offer': 'Zimmer: Doppelzimmer', 'created_at': datetime.datetime(2025, 10, 6, 13, 55, 18, 195026, tzinfo=datetime.timezone.utc), 'utm_source': 'fb', 'utm_medium': 'Facebook_Mobile_Reels', 'utm_campaign': 'Conversions_Hotel_Bemelmans_DEU', 'utm_term': 'Cold_Traffic_Conversions_Hotel_Bemelmans_DEU', 'utm_content': 'Grafik_1_Advent am Ritten', 'user_comment': '', 'fbclid': 'IwZXh0bgNhZW0BMABhZGlkAasrrpKSEUQBHrQyRa1AdOw96ax7dXZoqFypxoThi7Rudg8daveq68g7gpYOI2uO2GEjlkFh_aem_K79wHg9HhLRag_qyO_ibzQ', 'gclid': '', 'hotel_code': '123', 'hotel_name': 'Frangart Inn', 'room_type_code': None, 'room_classification_code': None, 'room_type': None, 'meta_account_id': None, 'google_account_id': None}
|
||||
|
||||
================================================================================
|
||||
Exploration complete! Output saved to schema_info.txt
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
OAuth2 authentication module for Meta/Facebook API.
|
||||
|
||||
Handles:
|
||||
- Initial OAuth2 flow for short-lived tokens
|
||||
- Exchange short-lived for long-lived tokens (60 days)
|
||||
- Automatic token refresh
|
||||
- Token persistence
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from requests_oauthlib import OAuth2Session
|
||||
from requests_oauthlib.compliance_fixes import facebook_compliance_fix
|
||||
|
||||
|
||||
class MetaOAuth2:
|
||||
"""Handle OAuth2 authentication flow for Meta/Facebook API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
app_secret: Optional[str] = None,
|
||||
redirect_uri: str = "https://localhost/",
|
||||
api_version: str = "v18.0",
|
||||
):
|
||||
"""
|
||||
Initialize OAuth2 handler.
|
||||
|
||||
Args:
|
||||
app_id: Facebook App ID (or set META_APP_ID env var)
|
||||
app_secret: Facebook App Secret (or set META_APP_SECRET env var)
|
||||
redirect_uri: OAuth redirect URI
|
||||
api_version: Facebook API version
|
||||
"""
|
||||
load_dotenv()
|
||||
|
||||
self.app_id = app_id or os.getenv("META_APP_ID")
|
||||
self.app_secret = app_secret or os.getenv("META_APP_SECRET")
|
||||
self.redirect_uri = redirect_uri
|
||||
self.api_version = api_version
|
||||
|
||||
if not self.app_id or not self.app_secret:
|
||||
raise ValueError(
|
||||
"App ID and App Secret are required. "
|
||||
"Provide them as arguments or set META_APP_ID and META_APP_SECRET env vars."
|
||||
)
|
||||
|
||||
self.authorization_base_url = f"https://www.facebook.com/{api_version}/dialog/oauth"
|
||||
self.token_url = f"https://graph.facebook.com/{api_version}/oauth/access_token"
|
||||
|
||||
# Scopes needed for ads insights
|
||||
self.scopes = ["ads_management", "ads_read"]
|
||||
|
||||
def get_authorization_url(self) -> tuple[str, str]:
|
||||
"""
|
||||
Get the authorization URL for the user to visit.
|
||||
|
||||
Returns:
|
||||
Tuple of (authorization_url, state)
|
||||
"""
|
||||
facebook = OAuth2Session(
|
||||
self.app_id,
|
||||
redirect_uri=self.redirect_uri,
|
||||
scope=self.scopes,
|
||||
)
|
||||
facebook = facebook_compliance_fix(facebook)
|
||||
|
||||
authorization_url, state = facebook.authorization_url(self.authorization_base_url)
|
||||
return authorization_url, state
|
||||
|
||||
def fetch_token(self, authorization_response: str) -> Dict[str, str]:
|
||||
"""
|
||||
Exchange the authorization code for an access token.
|
||||
|
||||
Args:
|
||||
authorization_response: The full redirect URL after authorization
|
||||
|
||||
Returns:
|
||||
Token dictionary containing access_token and other info
|
||||
"""
|
||||
facebook = OAuth2Session(
|
||||
self.app_id,
|
||||
redirect_uri=self.redirect_uri,
|
||||
scope=self.scopes,
|
||||
)
|
||||
facebook = facebook_compliance_fix(facebook)
|
||||
|
||||
token = facebook.fetch_token(
|
||||
self.token_url,
|
||||
client_secret=self.app_secret,
|
||||
authorization_response=authorization_response,
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
def exchange_for_long_lived_token(self, short_lived_token: str) -> Dict[str, any]:
|
||||
"""
|
||||
Exchange short-lived token for long-lived token (60 days).
|
||||
|
||||
Args:
|
||||
short_lived_token: The short-lived access token from OAuth flow
|
||||
|
||||
Returns:
|
||||
Dictionary with access_token and expires_in
|
||||
"""
|
||||
url = "https://graph.facebook.com/oauth/access_token"
|
||||
params = {
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": self.app_id,
|
||||
"client_secret": self.app_secret,
|
||||
"fb_exchange_token": short_lived_token,
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return {
|
||||
"access_token": data["access_token"],
|
||||
"expires_in": data.get("expires_in", 5184000), # Default 60 days
|
||||
"token_type": data.get("token_type", "bearer"),
|
||||
}
|
||||
|
||||
def get_token_info(self, access_token: str) -> Dict[str, any]:
|
||||
"""
|
||||
Get information about an access token including expiry.
|
||||
|
||||
Args:
|
||||
access_token: Access token to inspect
|
||||
|
||||
Returns:
|
||||
Dictionary with token info including expires_at, is_valid, etc.
|
||||
"""
|
||||
url = "https://graph.facebook.com/debug_token"
|
||||
params = {
|
||||
"input_token": access_token,
|
||||
"access_token": f"{self.app_id}|{self.app_secret}",
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data.get("data", {})
|
||||
|
||||
def interactive_auth(self, exchange_for_long_lived: bool = True) -> str:
|
||||
"""
|
||||
Run interactive OAuth2 flow to get access token.
|
||||
|
||||
Args:
|
||||
exchange_for_long_lived: If True, exchanges short-lived for long-lived token (60 days)
|
||||
|
||||
Returns:
|
||||
Access token string (long-lived if exchange_for_long_lived=True)
|
||||
"""
|
||||
print("\n" + "="*60)
|
||||
print("META/FACEBOOK OAUTH2 AUTHENTICATION")
|
||||
print("="*60)
|
||||
|
||||
# Get authorization URL
|
||||
auth_url, state = self.get_authorization_url()
|
||||
|
||||
print("\n1. Please visit this URL in your browser:")
|
||||
print(f"\n {auth_url}\n")
|
||||
print("2. Authorize the application")
|
||||
print("3. You will be redirected to localhost (this is expected)")
|
||||
print("4. Copy the FULL URL from your browser's address bar")
|
||||
print(" (it will look like: https://localhost/?code=...)")
|
||||
|
||||
# Wait for user to paste the redirect URL
|
||||
redirect_response = input("\nPaste the full redirect URL here: ").strip()
|
||||
|
||||
# Fetch the short-lived token
|
||||
print("\nFetching short-lived access token...")
|
||||
token = self.fetch_token(redirect_response)
|
||||
short_lived_token = token["access_token"]
|
||||
|
||||
# Exchange for long-lived token
|
||||
if exchange_for_long_lived:
|
||||
print("Exchanging for long-lived token (60 days)...")
|
||||
long_lived_data = self.exchange_for_long_lived_token(short_lived_token)
|
||||
access_token = long_lived_data["access_token"]
|
||||
expires_in = long_lived_data["expires_in"]
|
||||
|
||||
print(f"\n✅ Long-lived token obtained!")
|
||||
print(f" Valid for: {expires_in / 86400:.0f} days (~{expires_in / 3600:.0f} hours)")
|
||||
else:
|
||||
access_token = short_lived_token
|
||||
print("\n✅ Short-lived token obtained (valid for ~1-2 hours)")
|
||||
|
||||
print("="*60)
|
||||
|
||||
# Save token with metadata
|
||||
self._offer_to_save_token_with_metadata(access_token)
|
||||
|
||||
return access_token
|
||||
|
||||
def _offer_to_save_token_with_metadata(self, access_token: str):
|
||||
"""Offer to save the access token with metadata to both .env and JSON file."""
|
||||
save = input("\nWould you like to save this token? (y/n): ").strip().lower()
|
||||
|
||||
if save == "y":
|
||||
# Get token info
|
||||
try:
|
||||
token_info = self.get_token_info(access_token)
|
||||
expires_at = token_info.get("expires_at", 0)
|
||||
is_valid = token_info.get("is_valid", False)
|
||||
issued_at = token_info.get("issued_at", int(time.time()))
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get token info: {e}")
|
||||
expires_at = int(time.time()) + 5184000 # Assume 60 days
|
||||
is_valid = True
|
||||
issued_at = int(time.time())
|
||||
|
||||
# Save to .env
|
||||
env_path = Path(".env")
|
||||
if env_path.exists():
|
||||
env_content = env_path.read_text()
|
||||
else:
|
||||
env_content = ""
|
||||
|
||||
lines = env_content.split("\n")
|
||||
token_line = f"META_ACCESS_TOKEN={access_token}"
|
||||
updated = False
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("META_ACCESS_TOKEN="):
|
||||
lines[i] = token_line
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
lines.append(token_line)
|
||||
|
||||
env_path.write_text("\n".join(lines))
|
||||
print(f"\n✅ Token saved to {env_path}")
|
||||
|
||||
# Save metadata to JSON for token refresh logic
|
||||
token_metadata = {
|
||||
"access_token": access_token,
|
||||
"expires_at": expires_at,
|
||||
"issued_at": issued_at,
|
||||
"is_valid": is_valid,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
token_file = Path(".meta_token.json")
|
||||
token_file.write_text(json.dumps(token_metadata, indent=2))
|
||||
print(f"✅ Token metadata saved to {token_file}")
|
||||
|
||||
# Print expiry info
|
||||
if expires_at:
|
||||
expires_dt = datetime.fromtimestamp(expires_at)
|
||||
days_until_expiry = (expires_dt - datetime.now()).days
|
||||
print(f"\n📅 Token expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f" ({days_until_expiry} days from now)")
|
||||
else:
|
||||
print(f"\nAccess token: {access_token}")
|
||||
print("You can manually add it to .env as META_ACCESS_TOKEN")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run interactive OAuth2 flow."""
|
||||
try:
|
||||
oauth = MetaOAuth2()
|
||||
access_token = oauth.interactive_auth()
|
||||
print(f"\nYou can now use this access token with the insights grabber.")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"\nConfiguration error: {e}")
|
||||
print("\nPlease set META_APP_ID and META_APP_SECRET in .env")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -85,16 +85,36 @@ class TimescaleDBClient:
|
||||
|
||||
# Execute schema statement by statement for better error handling
|
||||
async with self.pool.acquire() as conn:
|
||||
statements = [s.strip() for s in schema_sql.split(';') if s.strip()]
|
||||
# Set a reasonable timeout for schema operations (5 minutes per statement)
|
||||
await conn.execute("SET statement_timeout = '300s'")
|
||||
|
||||
# Split statements and track their line numbers in the original file
|
||||
statements = []
|
||||
current_line = 1
|
||||
for stmt in schema_sql.split(';'):
|
||||
stmt_stripped = stmt.strip()
|
||||
if stmt_stripped:
|
||||
# Count newlines before this statement to get starting line
|
||||
statements.append((stmt_stripped, current_line))
|
||||
# Update line counter (count newlines in the original chunk including ';')
|
||||
current_line += stmt.count('\n') + 1
|
||||
|
||||
errors = []
|
||||
compression_warnings = []
|
||||
|
||||
for i, stmt in enumerate(statements, 1):
|
||||
for i, (stmt, line_num) in enumerate(statements, 1):
|
||||
if not stmt:
|
||||
continue
|
||||
|
||||
# Show progress for potentially slow operations
|
||||
stmt_lower = stmt.lower()
|
||||
if any(keyword in stmt_lower for keyword in ['refresh materialized view', 'drop schema', 'create index']):
|
||||
print(f" Executing statement {i} (line {line_num})...")
|
||||
|
||||
try:
|
||||
await conn.execute(stmt)
|
||||
except asyncio.TimeoutError:
|
||||
errors.append((i, line_num, "Statement execution timed out (>5 minutes)"))
|
||||
except Exception as stmt_error:
|
||||
error_msg = str(stmt_error).lower()
|
||||
|
||||
@@ -104,19 +124,22 @@ class TimescaleDBClient:
|
||||
continue
|
||||
elif "columnstore not enabled" in error_msg:
|
||||
# Track compression warnings separately
|
||||
compression_warnings.append(i)
|
||||
compression_warnings.append((i, line_num))
|
||||
elif "'nonetype' object has no attribute 'decode'" in error_msg:
|
||||
# Silently ignore decode errors (usually comments/extensions)
|
||||
continue
|
||||
elif "canceling statement due to statement timeout" in error_msg:
|
||||
# Handle PostgreSQL timeout errors
|
||||
errors.append((i, line_num, "Statement execution timed out (>5 minutes)"))
|
||||
else:
|
||||
# Real errors
|
||||
errors.append((i, stmt_error))
|
||||
errors.append((i, line_num, stmt_error))
|
||||
|
||||
# Report results
|
||||
if errors:
|
||||
print(f"⚠️ {len(errors)} error(s) during schema initialization:")
|
||||
for stmt_num, error in errors:
|
||||
print(f" Statement {stmt_num}: {error}")
|
||||
for stmt_num, line_num, error in errors:
|
||||
print(f" Statement {stmt_num} (line {line_num}): {error}")
|
||||
|
||||
if compression_warnings:
|
||||
print("ℹ️ Note: Data compression not available (TimescaleDB columnstore not enabled)")
|
||||
@@ -310,7 +333,6 @@ class TimescaleDBClient:
|
||||
account_id: str,
|
||||
data: Dict[str, Any],
|
||||
date_preset: str = "today",
|
||||
cache_metadata: bool = True,
|
||||
):
|
||||
"""
|
||||
Insert campaign-level insights data.
|
||||
@@ -321,10 +343,9 @@ class TimescaleDBClient:
|
||||
account_id: Ad account ID
|
||||
data: Insights data dictionary from Meta API
|
||||
date_preset: Date preset used
|
||||
cache_metadata: If True, automatically cache campaign metadata from insights data
|
||||
"""
|
||||
# Cache campaign metadata if requested and available in the insights data
|
||||
if cache_metadata and data.get("campaign_name"):
|
||||
# Auto-cache campaign metadata if available in the insights data
|
||||
if data.get("campaign_name"):
|
||||
await self.upsert_campaign(
|
||||
campaign_id=campaign_id,
|
||||
account_id=account_id,
|
||||
@@ -389,7 +410,6 @@ class TimescaleDBClient:
|
||||
account_id: str,
|
||||
data: Dict[str, Any],
|
||||
date_preset: str = "today",
|
||||
cache_metadata: bool = True,
|
||||
):
|
||||
"""
|
||||
Insert ad set level insights data.
|
||||
@@ -401,11 +421,8 @@ class TimescaleDBClient:
|
||||
account_id: Ad account ID
|
||||
data: Insights data dictionary from Meta API
|
||||
date_preset: Date preset used
|
||||
cache_metadata: If True, automatically cache adset/campaign metadata from insights data
|
||||
"""
|
||||
# Cache metadata if requested and available in the insights data
|
||||
if cache_metadata:
|
||||
# Cache adset metadata if available
|
||||
# Auto-cache adset metadata if available in the insights data
|
||||
# Note: Campaign should already exist from cache_campaigns_metadata or grab_campaign_insights
|
||||
# If it doesn't exist, the foreign key constraint will fail with a clear error
|
||||
# This is intentional - we should never silently create campaigns with 'Unknown' names
|
||||
@@ -465,6 +482,74 @@ class TimescaleDBClient:
|
||||
ctr, cpc, cpm, actions, date_preset, date_start, date_stop
|
||||
)
|
||||
|
||||
async def insert_campaign_insights_by_country(
|
||||
self,
|
||||
time: datetime,
|
||||
campaign_id: str,
|
||||
account_id: str,
|
||||
country: str,
|
||||
data: Dict[str, Any],
|
||||
date_preset: str = "today",
|
||||
):
|
||||
"""
|
||||
Insert campaign-level insights data broken down by country.
|
||||
|
||||
Args:
|
||||
time: Timestamp for the data point
|
||||
campaign_id: Campaign ID
|
||||
account_id: Ad account ID
|
||||
country: ISO 2-letter country code
|
||||
data: Insights data dictionary from Meta API
|
||||
date_preset: Date preset used
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO campaign_insights_by_country (
|
||||
time, campaign_id, account_id, country, impressions, clicks, spend, reach,
|
||||
ctr, cpc, cpm, actions, date_preset, date_start, date_stop, fetched_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW())
|
||||
ON CONFLICT (time, campaign_id, country)
|
||||
DO UPDATE SET
|
||||
impressions = EXCLUDED.impressions,
|
||||
clicks = EXCLUDED.clicks,
|
||||
spend = EXCLUDED.spend,
|
||||
reach = EXCLUDED.reach,
|
||||
ctr = EXCLUDED.ctr,
|
||||
cpc = EXCLUDED.cpc,
|
||||
cpm = EXCLUDED.cpm,
|
||||
actions = EXCLUDED.actions,
|
||||
date_preset = EXCLUDED.date_preset,
|
||||
date_start = EXCLUDED.date_start,
|
||||
date_stop = EXCLUDED.date_stop,
|
||||
fetched_at = NOW()
|
||||
"""
|
||||
|
||||
impressions = int(data.get("impressions", 0)) if data.get("impressions") else None
|
||||
clicks = int(data.get("clicks", 0)) if data.get("clicks") else None
|
||||
spend = float(data.get("spend", 0)) if data.get("spend") else None
|
||||
reach = int(data.get("reach", 0)) if data.get("reach") else None
|
||||
ctr = float(data.get("ctr", 0)) if data.get("ctr") else None
|
||||
cpc = float(data.get("cpc", 0)) if data.get("cpc") else None
|
||||
cpm = float(data.get("cpm", 0)) if data.get("cpm") else None
|
||||
|
||||
# Extract date range from Meta API response and convert to date objects
|
||||
from datetime import date as Date
|
||||
date_start = None
|
||||
date_stop = None
|
||||
if data.get("date_start"):
|
||||
date_start = Date.fromisoformat(data["date_start"])
|
||||
if data.get("date_stop"):
|
||||
date_stop = Date.fromisoformat(data["date_stop"])
|
||||
|
||||
import json
|
||||
actions = json.dumps(data.get("actions", [])) if data.get("actions") else None
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
query,
|
||||
time, campaign_id, account_id, country, impressions, clicks, spend, reach,
|
||||
ctr, cpc, cpm, actions, date_preset, date_start, date_stop
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# QUERY HELPERS
|
||||
# ========================================================================
|
||||
|
||||
@@ -1,301 +1,707 @@
|
||||
-- TimescaleDB Schema for Meta Ad Insights
|
||||
-- This schema is optimized for time-series data collection and dashboard queries
|
||||
|
||||
-- Enable TimescaleDB extension (run as superuser)
|
||||
|
||||
-- Cleanup schema public first.
|
||||
|
||||
---DROP SCHEMA IF EXISTS public CASCADE;
|
||||
|
||||
-- Recreate schema public.
|
||||
|
||||
CREATE SCHEMA public if not exists;
|
||||
|
||||
-- Set ownership to meta_user
|
||||
ALTER SCHEMA public OWNER TO meta_user;
|
||||
|
||||
-- ============================================================================
|
||||
-- EXTENSIONS
|
||||
-- ============================================================================
|
||||
-- Create TimescaleDB extension if it doesn't exist
|
||||
-- This provides time_bucket() and other time-series functions
|
||||
|
||||
--CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- ============================================================================
|
||||
-- MIGRATIONS (Add new columns to existing tables)
|
||||
-- METADATA TABLE
|
||||
-- ============================================================================
|
||||
-- This table stores account mappings and identifiers
|
||||
-- Data is loaded from metadata.yaml file
|
||||
|
||||
-- Add date_start and date_stop columns (idempotent - safe to run multiple times)
|
||||
ALTER TABLE IF EXISTS account_insights ADD COLUMN IF NOT EXISTS date_start DATE;
|
||||
ALTER TABLE IF EXISTS account_insights ADD COLUMN IF NOT EXISTS date_stop DATE;
|
||||
|
||||
ALTER TABLE IF EXISTS campaign_insights ADD COLUMN IF NOT EXISTS date_start DATE;
|
||||
ALTER TABLE IF EXISTS campaign_insights ADD COLUMN IF NOT EXISTS date_stop DATE;
|
||||
|
||||
ALTER TABLE IF EXISTS adset_insights ADD COLUMN IF NOT EXISTS date_start DATE;
|
||||
ALTER TABLE IF EXISTS adset_insights ADD COLUMN IF NOT EXISTS date_stop DATE;
|
||||
|
||||
-- ============================================================================
|
||||
-- METADATA TABLES (Regular PostgreSQL tables for caching)
|
||||
-- ============================================================================
|
||||
|
||||
-- Ad Accounts (rarely changes, cached)
|
||||
CREATE TABLE IF NOT EXISTS ad_accounts (
|
||||
account_id VARCHAR(50) PRIMARY KEY,
|
||||
account_name VARCHAR(255),
|
||||
currency VARCHAR(10),
|
||||
timezone_name VARCHAR(100),
|
||||
CREATE TABLE IF NOT EXISTS public.account_metadata (
|
||||
id SERIAL PRIMARY KEY,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
meta_account_id VARCHAR(100),
|
||||
google_account_id VARCHAR(100),
|
||||
alpinebits_hotel_code VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Ensure at least one account ID is provided
|
||||
CONSTRAINT at_least_one_account_id CHECK (
|
||||
meta_account_id IS NOT NULL OR
|
||||
google_account_id IS NOT NULL OR
|
||||
alpinebits_hotel_code IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- GEOTARGETS TABLE
|
||||
-- ============================================================================
|
||||
-- This table stores Google Ads geotarget metadata for location targeting
|
||||
-- Data is loaded from geotargets CSV file
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.geotargets (
|
||||
criteria_id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canonical_name VARCHAR(500),
|
||||
parent_id INTEGER,
|
||||
country_code VARCHAR(2),
|
||||
target_type VARCHAR(50),
|
||||
status VARCHAR(20),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Campaigns (metadata cache)
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
campaign_id VARCHAR(50) PRIMARY KEY,
|
||||
account_id VARCHAR(50) REFERENCES ad_accounts(account_id),
|
||||
campaign_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50),
|
||||
objective VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
-- Permission grants for Grafana user
|
||||
|
||||
CREATE INDEX idx_campaigns_account ON campaigns(account_id);
|
||||
GRANT USAGE ON SCHEMA public TO grafana;
|
||||
|
||||
-- Ad Sets (metadata cache)
|
||||
CREATE TABLE IF NOT EXISTS adsets (
|
||||
adset_id VARCHAR(50) PRIMARY KEY,
|
||||
campaign_id VARCHAR(50) REFERENCES campaigns(campaign_id),
|
||||
adset_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
-- Grant SELECT on all existing tables and views in the schema
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana;
|
||||
|
||||
CREATE INDEX idx_adsets_campaign ON adsets(campaign_id);
|
||||
-- Grant SELECT on all future tables and views in the schema
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT ON TABLES TO grafana;
|
||||
|
||||
-- ============================================================================
|
||||
-- TIME-SERIES TABLES (Hypertables)
|
||||
-- ============================================================================
|
||||
|
||||
-- Account-level insights (time-series data)
|
||||
CREATE TABLE IF NOT EXISTS account_insights (
|
||||
time TIMESTAMPTZ NOT NULL,
|
||||
account_id VARCHAR(50) NOT NULL REFERENCES ad_accounts(account_id),
|
||||
|
||||
-- Core metrics
|
||||
impressions BIGINT,
|
||||
clicks BIGINT,
|
||||
spend NUMERIC(12, 2),
|
||||
reach BIGINT,
|
||||
frequency NUMERIC(10, 4),
|
||||
|
||||
-- Calculated metrics
|
||||
ctr NUMERIC(10, 6), -- Click-through rate
|
||||
cpc NUMERIC(10, 4), -- Cost per click
|
||||
cpm NUMERIC(10, 4), -- Cost per mille (thousand impressions)
|
||||
cpp NUMERIC(10, 4), -- Cost per reach
|
||||
|
||||
-- Actions (stored as JSONB for flexibility)
|
||||
actions JSONB,
|
||||
cost_per_action_type JSONB,
|
||||
|
||||
-- Metadata
|
||||
date_preset VARCHAR(50),
|
||||
date_start DATE, -- Actual start date of the data range from Meta API
|
||||
date_stop DATE, -- Actual end date of the data range from Meta API
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Composite primary key
|
||||
PRIMARY KEY (time, account_id)
|
||||
);
|
||||
|
||||
-- Convert to hypertable (partition by time)
|
||||
SELECT create_hypertable('account_insights', 'time',
|
||||
if_not_exists => TRUE,
|
||||
chunk_time_interval => INTERVAL '1 day'
|
||||
);
|
||||
|
||||
-- Create index for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_account_insights_account_time
|
||||
ON account_insights (account_id, time DESC);
|
||||
|
||||
|
||||
-- Campaign-level insights (time-series data)
|
||||
CREATE TABLE IF NOT EXISTS campaign_insights (
|
||||
time TIMESTAMPTZ NOT NULL,
|
||||
campaign_id VARCHAR(50) NOT NULL REFERENCES campaigns(campaign_id),
|
||||
account_id VARCHAR(50) NOT NULL REFERENCES ad_accounts(account_id),
|
||||
|
||||
-- Core metrics
|
||||
impressions BIGINT,
|
||||
clicks BIGINT,
|
||||
spend NUMERIC(12, 2),
|
||||
reach BIGINT,
|
||||
|
||||
-- Calculated metrics
|
||||
ctr NUMERIC(10, 6),
|
||||
cpc NUMERIC(10, 4),
|
||||
cpm NUMERIC(10, 4),
|
||||
|
||||
-- Actions
|
||||
actions JSONB,
|
||||
|
||||
-- Metadata
|
||||
date_preset VARCHAR(50),
|
||||
date_start DATE, -- Actual start date of the data range from Meta API
|
||||
date_stop DATE, -- Actual end date of the data range from Meta API
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (time, campaign_id)
|
||||
);
|
||||
|
||||
-- Convert to hypertable
|
||||
SELECT create_hypertable('campaign_insights', 'time',
|
||||
if_not_exists => TRUE,
|
||||
chunk_time_interval => INTERVAL '1 day'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_campaign_insights_campaign_time
|
||||
ON campaign_insights (campaign_id, time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_campaign_insights_account_time
|
||||
ON campaign_insights (account_id, time DESC);
|
||||
|
||||
|
||||
-- Ad Set level insights (time-series data)
|
||||
CREATE TABLE IF NOT EXISTS adset_insights (
|
||||
time TIMESTAMPTZ NOT NULL,
|
||||
adset_id VARCHAR(50) NOT NULL REFERENCES adsets(adset_id),
|
||||
campaign_id VARCHAR(50) NOT NULL REFERENCES campaigns(campaign_id),
|
||||
account_id VARCHAR(50) NOT NULL REFERENCES ad_accounts(account_id),
|
||||
|
||||
-- Core metrics
|
||||
impressions BIGINT,
|
||||
clicks BIGINT,
|
||||
spend NUMERIC(12, 2),
|
||||
reach BIGINT,
|
||||
|
||||
-- Calculated metrics
|
||||
ctr NUMERIC(10, 6),
|
||||
cpc NUMERIC(10, 4),
|
||||
cpm NUMERIC(10, 4),
|
||||
|
||||
-- Actions
|
||||
actions JSONB,
|
||||
|
||||
-- Metadata
|
||||
date_preset VARCHAR(50),
|
||||
date_start DATE, -- Actual start date of the data range from Meta API
|
||||
date_stop DATE, -- Actual end date of the data range from Meta API
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (time, adset_id)
|
||||
);
|
||||
|
||||
-- Convert to hypertable
|
||||
SELECT create_hypertable('adset_insights', 'time',
|
||||
if_not_exists => TRUE,
|
||||
chunk_time_interval => INTERVAL '1 day'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_adset_insights_adset_time
|
||||
ON adset_insights (adset_id, time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_adset_insights_campaign_time
|
||||
ON adset_insights (campaign_id, time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_adset_insights_account_time
|
||||
ON adset_insights (account_id, time DESC);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- CONTINUOUS AGGREGATES (Pre-computed rollups for dashboards)
|
||||
-- VIEWS
|
||||
-- ============================================================================
|
||||
|
||||
-- Hourly aggregates for account insights
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS account_insights_hourly
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 hour', time) AS bucket,
|
||||
account_id,
|
||||
AVG(impressions) as avg_impressions,
|
||||
AVG(clicks) as avg_clicks,
|
||||
AVG(spend) as avg_spend,
|
||||
AVG(ctr) as avg_ctr,
|
||||
AVG(cpc) as avg_cpc,
|
||||
AVG(cpm) as avg_cpm,
|
||||
MAX(reach) as max_reach,
|
||||
COUNT(*) as sample_count
|
||||
FROM account_insights
|
||||
GROUP BY bucket, account_id;
|
||||
|
||||
-- Refresh policy: refresh last 2 days every hour
|
||||
SELECT add_continuous_aggregate_policy('account_insights_hourly',
|
||||
start_offset => INTERVAL '2 days',
|
||||
end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour',
|
||||
if_not_exists => TRUE
|
||||
);
|
||||
-- All views below reference data in separate schemas (meta, google, etc.)
|
||||
-- Add your view definitions here...
|
||||
|
||||
|
||||
-- Daily aggregates for account insights
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS account_insights_daily
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 day', time) AS bucket,
|
||||
account_id,
|
||||
AVG(impressions) as avg_impressions,
|
||||
SUM(impressions) as total_impressions,
|
||||
AVG(clicks) as avg_clicks,
|
||||
SUM(clicks) as total_clicks,
|
||||
AVG(spend) as avg_spend,
|
||||
SUM(spend) as total_spend,
|
||||
AVG(ctr) as avg_ctr,
|
||||
AVG(cpc) as avg_cpc,
|
||||
AVG(cpm) as avg_cpm,
|
||||
MAX(reach) as max_reach,
|
||||
COUNT(*) as sample_count
|
||||
FROM account_insights
|
||||
GROUP BY bucket, account_id;
|
||||
|
||||
-- Refresh policy: refresh last 7 days every 4 hours
|
||||
SELECT add_continuous_aggregate_policy('account_insights_daily',
|
||||
start_offset => INTERVAL '7 days',
|
||||
end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '4 hours',
|
||||
if_not_exists => TRUE
|
||||
);
|
||||
DROP VIEW IF EXISTS campaign_insights CASCADE;
|
||||
|
||||
-- general campaign insights view
|
||||
|
||||
-- ============================================================================
|
||||
-- DATA RETENTION POLICIES (Optional - uncomment to enable)
|
||||
-- ============================================================================
|
||||
|
||||
-- Keep raw data for 90 days, then drop
|
||||
-- SELECT add_retention_policy('account_insights', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
-- SELECT add_retention_policy('campaign_insights', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
-- SELECT add_retention_policy('adset_insights', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
|
||||
-- Compress data older than 7 days for better storage efficiency
|
||||
SELECT add_compression_policy('account_insights', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('campaign_insights', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('adset_insights', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER VIEWS FOR DASHBOARDS
|
||||
-- ============================================================================
|
||||
|
||||
-- Latest metrics per account
|
||||
CREATE OR REPLACE VIEW latest_account_metrics AS
|
||||
SELECT DISTINCT ON (account_id)
|
||||
account_id,
|
||||
time,
|
||||
CREATE VIEW campaign_insights AS
|
||||
SELECT date_start AS "time",
|
||||
account_id AS account_id,
|
||||
campaign_id,
|
||||
campaign_name,
|
||||
objective,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
reach,
|
||||
frequency
|
||||
FROM account_insights
|
||||
ORDER BY account_id, time DESC;
|
||||
cpp,
|
||||
frequency,
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'link_click'::text) AS link_click,
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'landing_page_view'::text) AS landing_page_view,
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'lead'::text) AS lead
|
||||
FROM meta.customcampaign_insights;
|
||||
|
||||
-- Campaign performance summary (last 24 hours)
|
||||
CREATE OR REPLACE VIEW campaign_performance_24h AS
|
||||
-- age and gender
|
||||
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS campaign_insights_by_gender_and_age CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW campaign_insights_by_gender_and_age AS
|
||||
SELECT date_start AS "time",
|
||||
account_id AS account_id,
|
||||
campaign_id,
|
||||
campaign_name,
|
||||
gender,
|
||||
age,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_gender;
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX idx_campaign_insights_by_gender_and_age_date
|
||||
ON campaign_insights_by_gender_and_age(time);
|
||||
|
||||
CREATE UNIQUE INDEX idx_campaign_insights_by_gender_and_age_unique
|
||||
ON campaign_insights_by_gender_and_age(time, campaign_id, gender, age);
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY campaign_insights_by_gender_and_age;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_gender CASCADE;
|
||||
|
||||
create view campaign_insights_by_gender as
|
||||
Select time,
|
||||
sum(clicks) as clicks,
|
||||
sum(link_click) as link_click,
|
||||
sum(lead) as lead,
|
||||
sum(landing_page_view) as landing_page_view,
|
||||
sum(spend) as spend,
|
||||
sum(reach) as reach,
|
||||
sum(impressions) as impressions,
|
||||
gender,
|
||||
campaign_id,
|
||||
campaign_name,
|
||||
account_id
|
||||
from campaign_insights_by_gender_and_age
|
||||
group by time, gender, account_id, campaign_id, campaign_name;
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_age CASCADE;
|
||||
|
||||
create view campaign_insights_by_age as
|
||||
Select time,
|
||||
sum(clicks) as clicks,
|
||||
sum(link_click) as link_click,
|
||||
sum(lead) as lead,
|
||||
sum(landing_page_view) as landing_page_view,
|
||||
sum(spend) as spend,
|
||||
sum(reach) as reach,
|
||||
sum(impressions) as impressions,
|
||||
age,
|
||||
campaign_id,
|
||||
campaign_name,
|
||||
account_id
|
||||
from campaign_insights_by_gender_and_age
|
||||
group by time, age, account_id, campaign_id, campaign_name;
|
||||
|
||||
|
||||
-- device
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_device_flattened CASCADE;
|
||||
|
||||
CREATE VIEW campaign_insights_by_device_flattened AS
|
||||
SELECT date_start AS "time",
|
||||
account_id AS account_id,
|
||||
campaign_id,
|
||||
device_platform,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_device;
|
||||
|
||||
-- country
|
||||
|
||||
--- campaign insights by country
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_country_flattened CASCADE;
|
||||
|
||||
CREATE VIEW campaign_insights_by_country_flattened AS
|
||||
SELECT date_start AS "time",
|
||||
account_id AS account_id,
|
||||
campaign_id,
|
||||
country,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_country;
|
||||
-- ============================================================================
|
||||
|
||||
-- account views
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_gender CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_gender AS
|
||||
SELECT
|
||||
c.campaign_id,
|
||||
c.campaign_name,
|
||||
c.account_id,
|
||||
SUM(ci.impressions) as total_impressions,
|
||||
SUM(ci.clicks) as total_clicks,
|
||||
SUM(ci.spend) as total_spend,
|
||||
AVG(ci.ctr) as avg_ctr,
|
||||
AVG(ci.cpc) as avg_cpc
|
||||
FROM campaigns c
|
||||
LEFT JOIN campaign_insights ci ON c.campaign_id = ci.campaign_id
|
||||
WHERE ci.time >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY c.campaign_id, c.campaign_name, c.account_id
|
||||
ORDER BY total_spend DESC;
|
||||
time,
|
||||
account_id,
|
||||
gender,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_gender
|
||||
GROUP BY time, account_id, gender;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_device CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_device AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
device_platform,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_device_flattened
|
||||
GROUP BY time, account_id, device_platform;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_age CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_age AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
age,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_age
|
||||
GROUP BY time, account_id, age;
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_gender_and_age CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_gender_and_age AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
gender,
|
||||
age,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_gender_and_age
|
||||
GROUP BY time, account_id, age, gender;
|
||||
|
||||
-- Aggregates campaign insights by country to account level
|
||||
DROP VIEW IF EXISTS account_insights_by_country CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_country AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
country,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_country_flattened
|
||||
GROUP BY time, account_id, country;
|
||||
|
||||
DROP VIEW IF EXISTS account_insights;
|
||||
CREATE VIEW account_insights AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead,
|
||||
SUM(reach) as reach,
|
||||
AVG(frequency) as frequency,
|
||||
avg(cpc) as cpc,
|
||||
avg(cpm) as cpm,
|
||||
avg(cpp) as cpp,
|
||||
avg(ctr) as ctr
|
||||
|
||||
FROM campaign_insights
|
||||
group by time, account_id;
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS ads_insights CASCADE;
|
||||
-- ads view
|
||||
CREATE MATERIALIZED VIEW ads_insights AS
|
||||
SELECT
|
||||
date_start AS time,
|
||||
name as ad_name,
|
||||
ins.account_id,
|
||||
ins.ad_id,
|
||||
ins.adset_id,
|
||||
ins.campaign_id,
|
||||
impressions,
|
||||
reach,
|
||||
clicks,
|
||||
(SELECT SUM((elem.value ->> 'value')::numeric)
|
||||
FROM jsonb_array_elements(ins.actions) AS elem
|
||||
WHERE (elem.value ->> 'action_type') = 'link_click') AS link_click,
|
||||
(SELECT SUM((elem.value ->> 'value')::numeric)
|
||||
FROM jsonb_array_elements(ins.actions) AS elem
|
||||
WHERE (elem.value ->> 'action_type') = 'lead') AS lead,
|
||||
(SELECT sum((value->>'value')::numeric)
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
spend,
|
||||
frequency,
|
||||
cpc,
|
||||
cpm,
|
||||
ctr,
|
||||
cpp
|
||||
FROM meta.ads_insights ins
|
||||
join meta.ads as a on a.id = ins.ad_id;
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX idx_ads_insights
|
||||
ON ads_insights(time, ad_id);
|
||||
|
||||
CREATE UNIQUE INDEX ads_insights_unique
|
||||
ON ads_insights(time, account_id, ad_id);
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY ads_insights;
|
||||
|
||||
DROP VIEW IF EXISTS adset_insights CASCADE;
|
||||
|
||||
CREATE VIEW adset_insights AS
|
||||
SELECT
|
||||
time,
|
||||
i.account_id,
|
||||
name as adset_name,
|
||||
i.adset_id,
|
||||
i.campaign_id,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
sum(link_click) as link_click,
|
||||
sum(lead) as lead,
|
||||
sum(landing_page_view) as landing_page_view,
|
||||
SUM(spend) AS spend,
|
||||
sum(reach),
|
||||
AVG(frequency) as frequency,
|
||||
avg(cpc) as cpc,
|
||||
avg(cpm) as cpm,
|
||||
avg(cpp) as cpp,
|
||||
avg(ctr) as ctr
|
||||
|
||||
FROM ads_insights as i left join meta.ad_sets as m on m.id = i.adset_id
|
||||
group by time, i.account_id, i.adset_id, i.campaign_id, adset_name;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS g_account_insights CASCADE;
|
||||
CREATE VIEW g_account_insights AS
|
||||
SELECT
|
||||
time,
|
||||
google_account_id,
|
||||
clicks,
|
||||
impressions,
|
||||
interactions,
|
||||
cost_micros,
|
||||
cost_micros / 1000000.0 as cost,
|
||||
leads,
|
||||
engagements,
|
||||
customer_currency_code,
|
||||
account_name,
|
||||
|
||||
-- CTR (Click-Through Rate)
|
||||
(clicks::numeric / impressions_nz) * 100 as ctr,
|
||||
|
||||
-- CPM (Cost Per Mille) in micros and standard units
|
||||
(cost_micros::numeric / impressions_nz) * 1000 as cpm_micros,
|
||||
(cost_micros::numeric / impressions_nz) * 1000 / 1000000.0 as cpm,
|
||||
|
||||
-- CPC (Cost Per Click) in micros and standard units
|
||||
cost_micros::numeric / clicks_nz as cpc_micros,
|
||||
cost_micros::numeric / clicks_nz / 1000000.0 as cpc,
|
||||
|
||||
-- CPL (Cost Per Lead) in micros and standard units
|
||||
cost_micros::numeric / leads_nz as cpl_micros,
|
||||
cost_micros::numeric / leads_nz / 1000000.0 as cpl,
|
||||
|
||||
-- Conversion Rate
|
||||
(leads::numeric / clicks_nz) * 100 as conversion_rate,
|
||||
|
||||
-- Engagement Rate
|
||||
(engagements::numeric / impressions_nz) * 100 as engagement_rate
|
||||
|
||||
FROM (
|
||||
SELECT
|
||||
segments_date as time,
|
||||
customer_id as google_account_id,
|
||||
sum(metrics_clicks) as clicks,
|
||||
sum(metrics_impressions) as impressions,
|
||||
sum(metrics_interactions) as interactions,
|
||||
sum(metrics_cost_micros) as cost_micros,
|
||||
sum(metrics_conversions) as leads,
|
||||
sum(metrics_engagements) as engagements,
|
||||
customer_currency_code,
|
||||
customer_descriptive_name as account_name,
|
||||
-- Null-safe denominators
|
||||
NULLIF(sum(metrics_clicks), 0) as clicks_nz,
|
||||
NULLIF(sum(metrics_impressions), 0) as impressions_nz,
|
||||
NULLIF(sum(metrics_conversions), 0) as leads_nz
|
||||
FROM google.account_performance_report
|
||||
GROUP BY google_account_id, time, customer_currency_code, account_name
|
||||
) base;
|
||||
|
||||
DROP VIEW IF EXISTS g_account_insights_by_country CASCADE;
|
||||
|
||||
CREATE VIEW g_account_insights_by_country AS
|
||||
select segments_date as time, customer_id as google_account_id,
|
||||
g.metrics_clicks as clicks,
|
||||
g.metrics_impressions as impressions,
|
||||
g.metrics_cost_micros as cost_micros,
|
||||
g.metrics_cost_micros / 1000000.0 as cost,
|
||||
g.metrics_conversions as conversions,
|
||||
g.metrics_conversions_value as conversion_value,
|
||||
metrics_interactions as interactions,
|
||||
m.country_code
|
||||
from google.geo_view_with_metrics as g join geotargets as m on m.criteria_id =
|
||||
g.geographic_view_country_criterion_id
|
||||
order by time;
|
||||
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS g_account_insights_device CASCADE;
|
||||
CREATE VIEW g_account_insights_device AS
|
||||
SELECT
|
||||
time,
|
||||
google_account_id,
|
||||
device,
|
||||
clicks,
|
||||
impressions,
|
||||
interactions,
|
||||
cost_micros,
|
||||
cost_micros / 1000000.0 as cost,
|
||||
leads,
|
||||
engagements,
|
||||
customer_currency_code,
|
||||
account_name,
|
||||
|
||||
-- CTR (Click-Through Rate)
|
||||
(clicks::numeric / impressions_nz) * 100 as ctr,
|
||||
|
||||
-- CPM (Cost Per Mille) in micros and standard units
|
||||
(cost_micros::numeric / impressions_nz) * 1000 as cpm_micros,
|
||||
(cost_micros::numeric / impressions_nz) * 1000 / 1000000.0 as cpm,
|
||||
|
||||
-- CPC (Cost Per Click) in micros and standard units
|
||||
cost_micros::numeric / clicks_nz as cpc_micros,
|
||||
cost_micros::numeric / clicks_nz / 1000000.0 as cpc,
|
||||
|
||||
-- CPL (Cost Per Lead) in micros and standard units
|
||||
cost_micros::numeric / leads_nz as cpl_micros,
|
||||
cost_micros::numeric / leads_nz / 1000000.0 as cpl,
|
||||
|
||||
-- Conversion Rate
|
||||
(leads::numeric / clicks_nz) * 100 as conversion_rate,
|
||||
|
||||
-- Engagement Rate
|
||||
(engagements::numeric / impressions_nz) * 100 as engagement_rate
|
||||
|
||||
FROM (
|
||||
SELECT
|
||||
segments_date as time,
|
||||
customer_id as google_account_id,
|
||||
segments_device as device,
|
||||
sum(metrics_clicks) as clicks,
|
||||
sum(metrics_impressions) as impressions,
|
||||
sum(metrics_interactions) as interactions,
|
||||
sum(metrics_cost_micros) as cost_micros,
|
||||
sum(metrics_conversions) as leads,
|
||||
sum(metrics_engagements) as engagements,
|
||||
customer_currency_code,
|
||||
customer_descriptive_name as account_name,
|
||||
-- Null-safe denominators
|
||||
NULLIF(sum(metrics_clicks), 0) as clicks_nz,
|
||||
NULLIF(sum(metrics_impressions), 0) as impressions_nz,
|
||||
NULLIF(sum(metrics_conversions), 0) as leads_nz
|
||||
FROM google.account_performance_report
|
||||
GROUP BY google_account_id, time, customer_currency_code, account_name, segments_device
|
||||
) base;
|
||||
|
||||
|
||||
-- Google campaign insights aggregated from campaign_metrics
|
||||
DROP VIEW IF EXISTS g_campaign_insights CASCADE;
|
||||
|
||||
CREATE VIEW g_campaign_insights AS
|
||||
SELECT
|
||||
segments_date as time,
|
||||
campaign_id,
|
||||
customer_id as google_account_id,
|
||||
campaign_name,
|
||||
SUM(metrics_clicks) as clicks,
|
||||
SUM(metrics_impressions) as impressions,
|
||||
SUM(metrics_interactions) as interactions,
|
||||
SUM(metrics_cost_micros) as cost_micros,
|
||||
SUM(metrics_cost_micros) / 1000000.0 as cost,
|
||||
SUM(metrics_conversions) as conversions,
|
||||
SUM(metrics_all_conversions) as all_conversions,
|
||||
SUM(metrics_conversions_value) as conversions_value,
|
||||
|
||||
-- CTR (Click-Through Rate)
|
||||
(SUM(metrics_clicks)::numeric / NULLIF(SUM(metrics_impressions), 0)) * 100 as ctr,
|
||||
|
||||
-- CPM (Cost Per Mille)
|
||||
(SUM(metrics_cost_micros)::numeric / NULLIF(SUM(metrics_impressions), 0)) * 1000 / 1000000.0 as cpm,
|
||||
|
||||
-- CPC (Cost Per Click)
|
||||
SUM(metrics_cost_micros)::numeric / NULLIF(SUM(metrics_clicks), 0) / 1000000.0 as cpc,
|
||||
|
||||
-- Cost per conversion
|
||||
SUM(metrics_cost_micros)::numeric / NULLIF(SUM(metrics_conversions), 0) / 1000000.0 as cost_per_conversion
|
||||
|
||||
FROM google.campaign_metrics
|
||||
GROUP BY customer_id, segments_date, campaign_id, campaign_name
|
||||
ORDER BY segments_date;
|
||||
|
||||
-- ============================================================================
|
||||
-- UNIFIED VIEW
|
||||
DROP VIEW IF EXISTS unified_account_insights CASCADE;
|
||||
|
||||
CREATE VIEW unified_account_insights AS
|
||||
SELECT
|
||||
COALESCE(g.time, m.time) as time,
|
||||
g.google_account_id::varchar as google_account_id,
|
||||
m.account_id as meta_account_id,
|
||||
|
||||
SUM(COALESCE(g.impressions, 0) + COALESCE(m.impressions, 0)) as impressions,
|
||||
SUM(COALESCE(g.clicks, 0) + COALESCE(m.clicks, 0)) as clicks,
|
||||
SUM(COALESCE(g.clicks, 0) + COALESCE(m.link_click, 0)) as link_clicks,
|
||||
|
||||
sum(g.leads + m.lead) as lead,
|
||||
SUM(COALESCE(g.cost, 0) + COALESCE(m.spend, 0)) as spend
|
||||
|
||||
|
||||
FROM account_metadata as am
|
||||
FULL OUTER JOIN g_account_insights as g ON g.google_account_id::varchar = am.google_account_id
|
||||
FULL OUTER JOIN account_insights as m ON m.account_id = am.meta_account_id AND m.time = g.time
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY 1;
|
||||
|
||||
-- Unified device-level insights combining Google and Meta data
|
||||
DROP VIEW IF EXISTS unified_account_insights_by_device CASCADE;
|
||||
|
||||
CREATE VIEW unified_account_insights_by_device AS
|
||||
-- Man verliert Tablet und mobile-app als device type ober who cares. TV und so bei google werd a als mobile innigschmisse. Taucht lei olle heiligen zeiten auf
|
||||
SELECT
|
||||
COALESCE(g.time, m.time) as time,
|
||||
g.google_account_id::varchar as google_account_id,
|
||||
m.account_id as meta_account_id,
|
||||
-- Normalize device types
|
||||
CASE
|
||||
WHEN UPPER(COALESCE(g.device, m.device_platform)) IN ('DESKTOP') THEN 'DESKTOP'
|
||||
ELSE 'MOBILE'
|
||||
END as device_type,
|
||||
SUM(COALESCE(g.impressions, 0) + COALESCE(m.impressions, 0)) as impressions,
|
||||
SUM(COALESCE(g.clicks, 0) + COALESCE(m.clicks, 0)) as clicks,
|
||||
SUM(COALESCE(g.clicks, 0) + COALESCE(m.link_click, 0)) as link_clicks,
|
||||
SUM(COALESCE(g.cost, 0) + COALESCE(m.spend, 0)) as spend
|
||||
FROM account_metadata as am
|
||||
FULL OUTER JOIN g_account_insights_device as g
|
||||
ON g.google_account_id::varchar = am.google_account_id
|
||||
FULL OUTER JOIN account_insights_by_device as m
|
||||
ON m.account_id = am.meta_account_id
|
||||
AND m.time = g.time
|
||||
AND CASE
|
||||
WHEN UPPER(g.device) IN ('DESKTOP') THEN 'DESKTOP'
|
||||
ELSE 'MOBILE'
|
||||
END = CASE
|
||||
WHEN UPPER(m.device_platform) IN ('DESKTOP') THEN 'DESKTOP'
|
||||
ELSE 'MOBILE'
|
||||
END
|
||||
GROUP BY
|
||||
COALESCE(g.time, m.time),
|
||||
g.google_account_id,
|
||||
m.account_id,
|
||||
CASE
|
||||
WHEN UPPER(COALESCE(g.device, m.device_platform)) IN ('DESKTOP') THEN 'DESKTOP'
|
||||
ELSE 'MOBILE'
|
||||
END
|
||||
ORDER BY 1, device_type;
|
||||
|
||||
-- Unified gender-level insights combining Google and Meta data for better audience analysis
|
||||
DROP VIEW IF EXISTS unified_account_insights_by_gender CASCADE;
|
||||
|
||||
CREATE VIEW unified_account_insights_by_gender AS
|
||||
SELECT
|
||||
m.time,
|
||||
m.account_id as meta_account_id,
|
||||
|
||||
m.gender,
|
||||
SUM(m.impressions) as impressions,
|
||||
SUM(m.clicks) as clicks,
|
||||
SUM(m.spend) as spend,
|
||||
SUM(m.link_click) as link_clicks,
|
||||
SUM(m.lead) as leads
|
||||
|
||||
FROM account_insights_by_gender as m
|
||||
GROUP BY m.time, m.account_id, m.gender
|
||||
ORDER BY m.time;
|
||||
|
||||
DROP VIEW IF EXISTS unified_account_insights_by_country CASCADE;
|
||||
|
||||
CREATE VIEW unified_account_insights_by_country AS
|
||||
SELECT
|
||||
COALESCE(g.time, m.time) as time,
|
||||
g.google_account_id::varchar as google_account_id,
|
||||
m.account_id as meta_account_id,
|
||||
COALESCE(g.country_code, m.country) as country_code,
|
||||
SUM(g.impressions) as google_impressions,
|
||||
SUM(m.impressions) as meta_impressions,
|
||||
SUM(COALESCE(g.impressions, 0) + COALESCE(m.impressions, 0)) as total_impressions,
|
||||
SUM(g.clicks) as google_clicks,
|
||||
SUM(m.clicks) as meta_clicks,
|
||||
SUM(COALESCE(g.clicks, 0) + COALESCE(m.clicks, 0)) as total_clicks,
|
||||
SUM(g.cost) as google_cost,
|
||||
SUM(m.spend) as meta_spend,
|
||||
SUM(COALESCE(g.cost, 0) + COALESCE(m.spend, 0)) as total_spend,
|
||||
SUM(m.link_click) as meta_link_clicks,
|
||||
SUM(m.lead) as meta_leads
|
||||
FROM account_metadata as am
|
||||
FULL OUTER JOIN g_account_insights_by_country as g
|
||||
ON g.google_account_id::varchar = am.google_account_id
|
||||
FULL OUTER JOIN account_insights_by_country as m
|
||||
ON m.account_id = am.meta_account_id
|
||||
AND m.time = g.time
|
||||
AND m.country = g.country_code
|
||||
GROUP BY COALESCE(g.time, m.time), g.google_account_id, m.account_id, COALESCE(g.country_code, m.country)
|
||||
ORDER BY COALESCE(g.time, m.time);
|
||||
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""
|
||||
Discover all ad accounts accessible via different methods:
|
||||
1. User ad accounts (personal access)
|
||||
2. Business Manager ad accounts (app-level access)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.adobjects.user import User
|
||||
from facebook_business.adobjects.business import Business
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
|
||||
def discover_accounts():
|
||||
"""Discover ad accounts through multiple methods."""
|
||||
load_dotenv()
|
||||
|
||||
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")
|
||||
return 1
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
print("="*70)
|
||||
print("AD ACCOUNT DISCOVERY")
|
||||
print("="*70)
|
||||
print()
|
||||
|
||||
# Method 1: User ad accounts (what we currently use)
|
||||
print("METHOD 1: User Ad Accounts (Personal Access)")
|
||||
print("-"*70)
|
||||
try:
|
||||
me = User(fbid='me')
|
||||
account_fields = ['name', 'account_id']
|
||||
user_accounts = me.get_ad_accounts(fields=account_fields)
|
||||
user_account_list = list(user_accounts)
|
||||
|
||||
print(f"Found {len(user_account_list)} user ad account(s):")
|
||||
for acc in user_account_list:
|
||||
print(f" - {acc.get('name')} ({acc.get('id')})")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting user accounts: {e}\n")
|
||||
user_account_list = []
|
||||
|
||||
# Method 2: Business Manager accounts
|
||||
print("METHOD 2: Business Manager Ad Accounts (App-Level Access)")
|
||||
print("-"*70)
|
||||
try:
|
||||
me = User(fbid='me')
|
||||
# Get businesses this user/app has access to
|
||||
businesses = me.get_businesses(fields=['id', 'name'])
|
||||
business_list = list(businesses)
|
||||
|
||||
if not business_list:
|
||||
print("No Business Managers found.")
|
||||
print()
|
||||
print("ℹ️ To access ad accounts at the app level, you need to:")
|
||||
print(" 1. Have a Meta Business Manager")
|
||||
print(" 2. Add your app to the Business Manager")
|
||||
print(" 3. Grant the app access to ad accounts")
|
||||
print()
|
||||
else:
|
||||
print(f"Found {len(business_list)} Business Manager(s):")
|
||||
all_business_accounts = []
|
||||
|
||||
for biz in business_list:
|
||||
biz_id = biz.get('id')
|
||||
biz_name = biz.get('name')
|
||||
print(f"\n Business: {biz_name} ({biz_id})")
|
||||
|
||||
try:
|
||||
business = Business(fbid=biz_id)
|
||||
# Get all ad accounts owned/managed by this business
|
||||
biz_accounts = business.get_owned_ad_accounts(
|
||||
fields=['id', 'name', 'account_status']
|
||||
)
|
||||
biz_account_list = list(biz_accounts)
|
||||
|
||||
print(f" Ad Accounts: {len(biz_account_list)}")
|
||||
for acc in biz_account_list:
|
||||
print(f" - {acc.get('name')} ({acc.get('id')})")
|
||||
all_business_accounts.append(acc)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error accessing business accounts: {e}")
|
||||
|
||||
print()
|
||||
print(f"Total Business Manager ad accounts: {len(all_business_accounts)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting businesses: {e}")
|
||||
print()
|
||||
|
||||
# Method 3: App-level access token (for reference)
|
||||
print()
|
||||
print("METHOD 3: App Access Token (NOT recommended for ad accounts)")
|
||||
print("-"*70)
|
||||
print("App access tokens can be generated with:")
|
||||
print(f" curl 'https://graph.facebook.com/oauth/access_token")
|
||||
print(f" ?client_id={app_id}")
|
||||
print(f" &client_secret=YOUR_SECRET")
|
||||
print(f" &grant_type=client_credentials'")
|
||||
print()
|
||||
print("⚠️ However, app access tokens CANNOT access ad accounts directly.")
|
||||
print(" The Marketing API requires user-level permissions for privacy/security.")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("="*70)
|
||||
print("SUMMARY")
|
||||
print("="*70)
|
||||
print(f"User Ad Accounts: {len(user_account_list)}")
|
||||
print()
|
||||
print("💡 RECOMMENDATION:")
|
||||
print(" The current implementation using User.get_ad_accounts() is correct.")
|
||||
print(" To access MORE ad accounts, you have two options:")
|
||||
print()
|
||||
print(" Option 1: Use a System User token from Business Manager")
|
||||
print(" (grants access to all Business Manager ad accounts)")
|
||||
print()
|
||||
print(" Option 2: Have other users authorize your app")
|
||||
print(" (each user's token will see their ad accounts)")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(discover_accounts())
|
||||
228783
src/meta_api_grabber/geotargets-2025-10-29.csv
Normal file
228783
src/meta_api_grabber/geotargets-2025-10-29.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
Async script to grab ad insights data from Meta's Marketing API.
|
||||
Conservative rate limiting to avoid API limits.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
from .rate_limiter import MetaRateLimiter
|
||||
|
||||
|
||||
class MetaInsightsGrabber:
|
||||
"""
|
||||
Async grabber for Meta ad insights with intelligent rate limiting.
|
||||
|
||||
Features:
|
||||
- Monitors x-fb-ads-insights-throttle header
|
||||
- Auto-throttles when approaching rate limits (>75%)
|
||||
- Exponential backoff on rate limit errors
|
||||
- Automatic retries with progressive delays
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str = None):
|
||||
"""
|
||||
Initialize the grabber with credentials from environment.
|
||||
|
||||
Args:
|
||||
access_token: Optional access token. If not provided, will load from env.
|
||||
"""
|
||||
load_dotenv()
|
||||
|
||||
self.access_token = access_token or os.getenv("META_ACCESS_TOKEN")
|
||||
self.app_secret = os.getenv("META_APP_SECRET")
|
||||
self.app_id = os.getenv("META_APP_ID")
|
||||
self.ad_account_id = os.getenv("META_AD_ACCOUNT_ID")
|
||||
|
||||
if not self.access_token:
|
||||
raise ValueError(
|
||||
"Access token is required. Either:\n"
|
||||
"1. Set META_ACCESS_TOKEN in .env, or\n"
|
||||
"2. Run 'uv run python src/meta_api_grabber/auth.py' to get a token via OAuth2, or\n"
|
||||
"3. Pass access_token to the constructor"
|
||||
)
|
||||
|
||||
if not all([self.app_secret, self.app_id, self.ad_account_id]):
|
||||
raise ValueError(
|
||||
"Missing required environment variables (META_APP_SECRET, META_APP_ID, META_AD_ACCOUNT_ID). "
|
||||
"Please check your .env file against .env.example"
|
||||
)
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
FacebookAdsApi.init(
|
||||
app_id=self.app_id,
|
||||
app_secret=self.app_secret,
|
||||
access_token=self.access_token,
|
||||
)
|
||||
|
||||
self.ad_account = AdAccount(self.ad_account_id)
|
||||
|
||||
# Rate limiter with backoff (Meta best practices)
|
||||
self.rate_limiter = MetaRateLimiter(
|
||||
base_delay=2.0,
|
||||
throttle_threshold=75.0,
|
||||
max_retry_delay=300.0,
|
||||
max_retries=5,
|
||||
)
|
||||
|
||||
async def _rate_limited_request(self, func, *args, **kwargs):
|
||||
"""
|
||||
Execute a request with intelligent rate limiting and backoff.
|
||||
|
||||
Monitors x-fb-ads-insights-throttle header and auto-throttles
|
||||
when usage exceeds 75%. Implements exponential backoff on errors.
|
||||
"""
|
||||
return await self.rate_limiter.execute_with_retry(func, *args, **kwargs)
|
||||
|
||||
async def get_account_insights(
|
||||
self,
|
||||
date_preset: str = "last_7d",
|
||||
fields: List[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get account-level insights for a given time period.
|
||||
|
||||
Args:
|
||||
date_preset: Time period (last_7d, last_14d, last_30d, etc.)
|
||||
fields: List of fields to retrieve. If None, uses default interesting fields.
|
||||
|
||||
Returns:
|
||||
Dictionary containing insights data
|
||||
"""
|
||||
if fields is None:
|
||||
# Default interesting fields - conservative selection
|
||||
fields = [
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.cpc,
|
||||
AdsInsights.Field.cpm,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.reach,
|
||||
AdsInsights.Field.frequency,
|
||||
AdsInsights.Field.actions,
|
||||
AdsInsights.Field.cost_per_action_type,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": "account",
|
||||
}
|
||||
|
||||
print(f"Fetching account insights for {date_preset}...")
|
||||
insights = await self._rate_limited_request(
|
||||
self.ad_account.get_insights,
|
||||
fields=fields,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# Convert to dict
|
||||
result = {
|
||||
"account_id": self.ad_account_id,
|
||||
"date_preset": date_preset,
|
||||
"insights": [dict(insight) for insight in insights],
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def get_campaign_insights(
|
||||
self,
|
||||
date_preset: str = "last_7d",
|
||||
limit: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get campaign-level insights.
|
||||
|
||||
Args:
|
||||
date_preset: Time period
|
||||
limit: Maximum number of campaigns to fetch (conservative default)
|
||||
|
||||
Returns:
|
||||
Dictionary containing campaign insights
|
||||
"""
|
||||
fields = [
|
||||
AdsInsights.Field.campaign_name,
|
||||
AdsInsights.Field.campaign_id,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpc,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": "campaign",
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
print(f"Fetching campaign insights for {date_preset} (limit: {limit})...")
|
||||
insights = await self._rate_limited_request(
|
||||
self.ad_account.get_insights,
|
||||
fields=fields,
|
||||
params=params,
|
||||
)
|
||||
|
||||
result = {
|
||||
"account_id": self.ad_account_id,
|
||||
"date_preset": date_preset,
|
||||
"level": "campaign",
|
||||
"campaigns": [dict(insight) for insight in insights],
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def get_ad_set_insights(
|
||||
self,
|
||||
date_preset: str = "last_7d",
|
||||
limit: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get ad set level insights.
|
||||
|
||||
Args:
|
||||
date_preset: Time period
|
||||
limit: Maximum number of ad sets to fetch
|
||||
|
||||
Returns:
|
||||
Dictionary containing ad set insights
|
||||
"""
|
||||
fields = [
|
||||
AdsInsights.Field.adset_name,
|
||||
AdsInsights.Field.adset_id,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpm,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": "adset",
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
print(f"Fetching ad set insights for {date_preset} (limit: {limit})...")
|
||||
insights = await self._rate_limited_request(
|
||||
self.ad_account.get_insights,
|
||||
fields=fields,
|
||||
params=params,
|
||||
)
|
||||
|
||||
result = {
|
||||
"account_id": self.ad_account_id,
|
||||
"date_preset": date_preset,
|
||||
"level": "adset",
|
||||
"ad_sets": [dict(insight) for insight in insights],
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def grab_all_insights(self, date_preset: str = "last_7d") -> Dict[str, Any]:
|
||||
"""
|
||||
Grab all interesting insights (account, campaign, ad set level).
|
||||
|
||||
Args:
|
||||
date_preset: Time period to fetch
|
||||
|
||||
Returns:
|
||||
Dictionary containing all insights
|
||||
"""
|
||||
print(f"\nStarting conservative data grab for {date_preset}...")
|
||||
print(f"Rate limit: {self.request_delay}s between requests\n")
|
||||
|
||||
# Fetch sequentially to be conservative
|
||||
account_insights = await self.get_account_insights(date_preset)
|
||||
campaign_insights = await self.get_campaign_insights(date_preset, limit=10)
|
||||
ad_set_insights = await self.get_ad_set_insights(date_preset, limit=10)
|
||||
|
||||
result = {
|
||||
"account": account_insights,
|
||||
"campaigns": campaign_insights,
|
||||
"ad_sets": ad_set_insights,
|
||||
"summary": {
|
||||
"date_preset": date_preset,
|
||||
"total_requests": 3,
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def save_insights_to_json(
|
||||
self,
|
||||
insights_data: Dict[str, Any],
|
||||
output_dir: str = "data",
|
||||
) -> Path:
|
||||
"""
|
||||
Save insights data to a JSON file.
|
||||
|
||||
Args:
|
||||
insights_data: The insights data to save
|
||||
output_dir: Directory to save the file
|
||||
|
||||
Returns:
|
||||
Path to the saved file
|
||||
"""
|
||||
# Create output directory if it doesn't exist
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"meta_insights_{timestamp}.json"
|
||||
filepath = output_path / filename
|
||||
|
||||
# Save to JSON with pretty formatting
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: filepath.write_text(
|
||||
json.dumps(insights_data, indent=2, default=str)
|
||||
)
|
||||
)
|
||||
|
||||
print(f"\nData saved to: {filepath}")
|
||||
print(f"File size: {filepath.stat().st_size / 1024:.2f} KB")
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point for the insights grabber."""
|
||||
try:
|
||||
grabber = MetaInsightsGrabber()
|
||||
|
||||
# Grab insights for the last 7 days
|
||||
insights = await grabber.grab_all_insights(date_preset=AdsInsights.DatePreset.last_7d)
|
||||
|
||||
# Save to JSON
|
||||
filepath = await grabber.save_insights_to_json(insights)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*50)
|
||||
print("SUMMARY")
|
||||
print("="*50)
|
||||
print(f"Account ID: {grabber.ad_account_id}")
|
||||
print(f"Date Range: last_7d")
|
||||
print(f"Output File: {filepath}")
|
||||
|
||||
if insights["account"]["insights"]:
|
||||
account_data = insights["account"]["insights"][0]
|
||||
print(f"\nAccount Metrics:")
|
||||
print(f" Impressions: {account_data.get('impressions', 'N/A')}")
|
||||
print(f" Clicks: {account_data.get('clicks', 'N/A')}")
|
||||
print(f" Spend: ${account_data.get('spend', 'N/A')}")
|
||||
print(f" CTR: {account_data.get('ctr', 'N/A')}%")
|
||||
|
||||
print(f"\nCampaigns fetched: {len(insights['campaigns']['campaigns'])}")
|
||||
print(f"Ad Sets fetched: {len(insights['ad_sets']['ad_sets'])}")
|
||||
print("\n" + "="*50)
|
||||
|
||||
except ValueError as e:
|
||||
print(f"Configuration error: {e}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
exit(exit_code)
|
||||
@@ -1,6 +0,0 @@
|
||||
def main():
|
||||
print("Hello from meta-api-grabber!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,657 +0,0 @@
|
||||
"""
|
||||
Rate limiting and backoff mechanism for Meta Marketing API.
|
||||
|
||||
Based on Meta's best practices:
|
||||
https://developers.facebook.com/docs/marketing-api/insights/best-practices/
|
||||
https://developers.facebook.com/docs/graph-api/overview/rate-limiting
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class MetaRateLimiter:
|
||||
"""
|
||||
Rate limiter with exponential backoff for Meta Marketing API.
|
||||
|
||||
Features:
|
||||
- Monitors X-App-Usage header (platform rate limits)
|
||||
- Monitors X-Ad-Account-Usage header (ad account specific)
|
||||
- Monitors X-Business-Use-Case-Usage header (business use case limits)
|
||||
- Monitors x-fb-ads-insights-throttle header (legacy)
|
||||
- Automatic throttling when usage > 75%
|
||||
- Exponential backoff on rate limit errors
|
||||
- Uses reset_time_duration and estimated_time_to_regain_access
|
||||
- Configurable thresholds
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_delay: float = 2.0,
|
||||
throttle_threshold: float = 75.0,
|
||||
max_retry_delay: float = 300.0, # 5 minutes
|
||||
max_retries: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
base_delay: Base delay between requests in seconds
|
||||
throttle_threshold: Throttle when usage exceeds this % (0-100)
|
||||
max_retry_delay: Maximum delay for exponential backoff
|
||||
max_retries: Maximum number of retries on rate limit errors
|
||||
"""
|
||||
self.base_delay = base_delay
|
||||
self.throttle_threshold = throttle_threshold
|
||||
self.max_retry_delay = max_retry_delay
|
||||
self.max_retries = max_retries
|
||||
|
||||
# Track current usage percentages from different headers
|
||||
# X-App-Usage (platform rate limits)
|
||||
self.app_call_count: float = 0.0
|
||||
self.app_total_cputime: float = 0.0
|
||||
self.app_total_time: float = 0.0
|
||||
|
||||
# X-Ad-Account-Usage (ad account specific) - tracked per account
|
||||
# Key: account_id (e.g., "act_123456789"), Value: dict with metrics
|
||||
self.ad_account_usage: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# X-Business-Use-Case-Usage (business use case limits)
|
||||
self.buc_usage: List[Dict[str, Any]] = []
|
||||
self.estimated_time_to_regain_access: int = 0 # minutes
|
||||
|
||||
# Legacy x-fb-ads-insights-throttle
|
||||
self.legacy_app_usage_pct: float = 0.0
|
||||
self.legacy_account_usage_pct: float = 0.0
|
||||
|
||||
self.last_check_time: float = time.time()
|
||||
|
||||
# Stats
|
||||
self.total_requests: int = 0
|
||||
self.throttled_requests: int = 0
|
||||
self.rate_limit_errors: int = 0
|
||||
|
||||
def _get_headers(self, response: Any) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract headers from various response object types.
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
|
||||
Returns:
|
||||
Dictionary of headers or None
|
||||
"""
|
||||
# Facebook SDK response object
|
||||
if hasattr(response, '_headers'):
|
||||
return response._headers
|
||||
elif hasattr(response, 'headers'):
|
||||
return response.headers
|
||||
elif hasattr(response, '_api_response'):
|
||||
return getattr(response._api_response, 'headers', None)
|
||||
return None
|
||||
|
||||
def parse_x_app_usage(self, response: Any) -> Dict[str, float]:
|
||||
"""
|
||||
Parse X-App-Usage header (Platform rate limits).
|
||||
|
||||
Header format: {"call_count": 28, "total_time": 25, "total_cputime": 25}
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
|
||||
Returns:
|
||||
Dictionary with call_count, total_time, total_cputime
|
||||
"""
|
||||
try:
|
||||
headers = self._get_headers(response)
|
||||
if headers:
|
||||
header_value = headers.get('x-app-usage') or headers.get('X-App-Usage', '')
|
||||
if header_value:
|
||||
logger.debug(f"X-App-Usage header: {header_value}")
|
||||
data = json.loads(header_value)
|
||||
result = {
|
||||
'call_count': float(data.get('call_count', 0)),
|
||||
'total_time': float(data.get('total_time', 0)),
|
||||
'total_cputime': float(data.get('total_cputime', 0)),
|
||||
}
|
||||
logger.debug(f"Parsed X-App-Usage: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse X-App-Usage header: {e}")
|
||||
return {'call_count': 0.0, 'total_time': 0.0, 'total_cputime': 0.0}
|
||||
|
||||
def parse_x_ad_account_usage(self, response: Any) -> Optional[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 metrics, or None if header not present.
|
||||
To determine account_id, check response object or URL.
|
||||
"""
|
||||
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 None
|
||||
|
||||
def _extract_account_id(self, response: Any) -> Optional[str]:
|
||||
"""
|
||||
Extract account ID from response object.
|
||||
|
||||
Tries multiple methods to find the account ID from the response.
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
|
||||
Returns:
|
||||
Account ID string (e.g., "act_123456789") or None
|
||||
"""
|
||||
# Try to get account_id from response attributes
|
||||
if hasattr(response, 'account_id'):
|
||||
return response.account_id
|
||||
if hasattr(response, '_data') and isinstance(response._data, dict):
|
||||
return response._data.get('account_id')
|
||||
|
||||
# Try to get from parent object
|
||||
if hasattr(response, '_parent_object'):
|
||||
parent = response._parent_object
|
||||
if hasattr(parent, 'get_id'):
|
||||
return parent.get_id()
|
||||
if hasattr(parent, '_data') and isinstance(parent._data, dict):
|
||||
return parent._data.get('account_id') or parent._data.get('id')
|
||||
|
||||
# Try to get from API context
|
||||
if hasattr(response, '_api_context'):
|
||||
context = response._api_context
|
||||
if hasattr(context, 'account_id'):
|
||||
return context.account_id
|
||||
|
||||
return None
|
||||
|
||||
def parse_x_business_use_case_usage(self, response: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse X-Business-Use-Case-Usage header (Business use case limits).
|
||||
|
||||
Header format: {
|
||||
"business-id": [{
|
||||
"type": "ads_management",
|
||||
"call_count": 95,
|
||||
"total_cputime": 20,
|
||||
"total_time": 20,
|
||||
"estimated_time_to_regain_access": 0,
|
||||
"ads_api_access_tier": "development_access"
|
||||
}],
|
||||
...
|
||||
}
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
|
||||
Returns:
|
||||
List of usage dictionaries for each business use case
|
||||
"""
|
||||
try:
|
||||
headers = self._get_headers(response)
|
||||
if headers:
|
||||
header_value = headers.get('x-business-use-case-usage') or headers.get('X-Business-Use-Case-Usage', '')
|
||||
if header_value:
|
||||
logger.debug(f"X-Business-Use-Case-Usage header: {header_value}")
|
||||
data = json.loads(header_value)
|
||||
# Flatten the nested structure
|
||||
all_usage = []
|
||||
for business_id, use_cases in data.items():
|
||||
if isinstance(use_cases, list):
|
||||
for use_case in use_cases:
|
||||
use_case['business_id'] = business_id
|
||||
all_usage.append(use_case)
|
||||
logger.debug(f"Parsed X-Business-Use-Case-Usage: {len(all_usage)} use cases")
|
||||
return all_usage
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse X-Business-Use-Case-Usage header: {e}")
|
||||
return []
|
||||
|
||||
def parse_throttle_header(self, response: Any) -> Dict[str, float]:
|
||||
"""
|
||||
Parse x-fb-ads-insights-throttle header from response (legacy).
|
||||
|
||||
Header format: {"app_id_util_pct": 25.5, "acc_id_util_pct": 10.0}
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
|
||||
Returns:
|
||||
Dictionary with app_id_util_pct and acc_id_util_pct
|
||||
"""
|
||||
try:
|
||||
headers = self._get_headers(response)
|
||||
if headers:
|
||||
throttle_header = headers.get('x-fb-ads-insights-throttle', '')
|
||||
if throttle_header:
|
||||
logger.debug(f"x-fb-ads-insights-throttle header: {throttle_header}")
|
||||
throttle_data = json.loads(throttle_header)
|
||||
result = {
|
||||
'app_id_util_pct': float(throttle_data.get('app_id_util_pct', 0)),
|
||||
'acc_id_util_pct': float(throttle_data.get('acc_id_util_pct', 0)),
|
||||
}
|
||||
logger.debug(f"Parsed x-fb-ads-insights-throttle: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse x-fb-ads-insights-throttle header: {e}")
|
||||
return {'app_id_util_pct': 0.0, 'acc_id_util_pct': 0.0}
|
||||
|
||||
def update_usage(self, response: Any, account_id: Optional[str] = None):
|
||||
"""
|
||||
Update usage statistics from all API response headers.
|
||||
|
||||
Parses and updates metrics from:
|
||||
- X-App-Usage
|
||||
- X-Ad-Account-Usage (per account)
|
||||
- X-Business-Use-Case-Usage
|
||||
- x-fb-ads-insights-throttle (legacy)
|
||||
|
||||
Args:
|
||||
response: API response object
|
||||
account_id: Optional account ID (e.g., "act_123456789").
|
||||
If not provided, will attempt to extract from response.
|
||||
"""
|
||||
# Parse all headers
|
||||
app_usage = self.parse_x_app_usage(response)
|
||||
ad_account_usage = self.parse_x_ad_account_usage(response)
|
||||
buc_usage = self.parse_x_business_use_case_usage(response)
|
||||
legacy_throttle = self.parse_throttle_header(response)
|
||||
|
||||
# Update X-App-Usage metrics
|
||||
self.app_call_count = app_usage['call_count']
|
||||
self.app_total_cputime = app_usage['total_cputime']
|
||||
self.app_total_time = app_usage['total_time']
|
||||
|
||||
# Update X-Ad-Account-Usage metrics (per account)
|
||||
if ad_account_usage:
|
||||
# Try to get account_id
|
||||
if not account_id:
|
||||
account_id = self._extract_account_id(response)
|
||||
|
||||
# Use 'unknown' as fallback if we can't determine account
|
||||
if not account_id:
|
||||
account_id = 'unknown'
|
||||
logger.debug("Could not determine account_id, using 'unknown'")
|
||||
|
||||
# Store usage for this account
|
||||
self.ad_account_usage[account_id] = ad_account_usage
|
||||
logger.debug(f"Updated ad account usage for {account_id}")
|
||||
|
||||
# Update X-Business-Use-Case-Usage metrics
|
||||
self.buc_usage = buc_usage
|
||||
# Find the maximum estimated_time_to_regain_access across all use cases
|
||||
if buc_usage:
|
||||
self.estimated_time_to_regain_access = max(
|
||||
(uc.get('estimated_time_to_regain_access', 0) for uc in buc_usage),
|
||||
default=0
|
||||
)
|
||||
|
||||
# Update legacy metrics
|
||||
self.legacy_app_usage_pct = legacy_throttle['app_id_util_pct']
|
||||
self.legacy_account_usage_pct = legacy_throttle['acc_id_util_pct']
|
||||
|
||||
self.last_check_time = time.time()
|
||||
|
||||
# Log warnings if approaching limits
|
||||
self._log_rate_limit_warnings()
|
||||
|
||||
def _log_rate_limit_warnings(self):
|
||||
"""Log warnings if any rate limit metric is approaching threshold."""
|
||||
warnings = []
|
||||
|
||||
# Check X-App-Usage metrics
|
||||
if self.app_call_count > self.throttle_threshold:
|
||||
warnings.append(f"App call count: {self.app_call_count:.1f}%")
|
||||
if self.app_total_cputime > self.throttle_threshold:
|
||||
warnings.append(f"App CPU time: {self.app_total_cputime:.1f}%")
|
||||
if self.app_total_time > self.throttle_threshold:
|
||||
warnings.append(f"App total time: {self.app_total_time:.1f}%")
|
||||
|
||||
# Check X-Ad-Account-Usage (per account)
|
||||
for account_id, usage in self.ad_account_usage.items():
|
||||
acc_pct = usage.get('acc_id_util_pct', 0)
|
||||
if acc_pct > self.throttle_threshold:
|
||||
warnings.append(f"Account {account_id}: {acc_pct:.1f}%")
|
||||
reset_time = usage.get('reset_time_duration', 0)
|
||||
if reset_time > 0:
|
||||
warnings.append(f"Resets in {reset_time}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.legacy_app_usage_pct,
|
||||
self.legacy_account_usage_pct,
|
||||
]
|
||||
|
||||
# Add ad account usage percentages (per account)
|
||||
for usage in self.ad_account_usage.values():
|
||||
usage_values.append(usage.get('acc_id_util_pct', 0))
|
||||
|
||||
# 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:
|
||||
"""
|
||||
Check if we should throttle based on current usage.
|
||||
|
||||
Returns:
|
||||
True if usage exceeds threshold
|
||||
"""
|
||||
return self.get_max_usage_pct() > self.throttle_threshold
|
||||
|
||||
def get_throttle_delay(self) -> float:
|
||||
"""
|
||||
Calculate delay based on current usage and reset times.
|
||||
|
||||
Uses estimated_time_to_regain_access and reset_time_duration when available.
|
||||
|
||||
Returns:
|
||||
Delay in seconds
|
||||
"""
|
||||
max_usage = self.get_max_usage_pct()
|
||||
|
||||
if max_usage < self.throttle_threshold:
|
||||
return self.base_delay
|
||||
|
||||
# If we have estimated_time_to_regain_access from BUC header, use it
|
||||
if self.estimated_time_to_regain_access > 0:
|
||||
# Convert minutes to seconds and use as delay
|
||||
delay = self.estimated_time_to_regain_access * 60
|
||||
logger.info(f"Using BUC estimated_time_to_regain_access: {self.estimated_time_to_regain_access} min ({delay}s)")
|
||||
return min(delay, self.max_retry_delay)
|
||||
|
||||
# Check if any ad account has reset_time_duration and high usage
|
||||
for account_id, usage in self.ad_account_usage.items():
|
||||
acc_pct = usage.get('acc_id_util_pct', 0)
|
||||
reset_time = usage.get('reset_time_duration', 0)
|
||||
if reset_time > 0 and acc_pct >= 90:
|
||||
# Use a fraction of reset_time_duration as delay
|
||||
delay = min(reset_time * 0.5, self.max_retry_delay)
|
||||
logger.info(f"Using Ad Account {account_id} reset_time_duration: {reset_time}s (delay: {delay}s)")
|
||||
return delay
|
||||
|
||||
# Progressive delay based on usage
|
||||
# 75% = base_delay, 90% = 2x, 95% = 5x, 99% = 10x
|
||||
if max_usage >= 95:
|
||||
multiplier = 10.0
|
||||
elif max_usage >= 90:
|
||||
multiplier = 5.0
|
||||
elif max_usage >= 85:
|
||||
multiplier = 3.0
|
||||
else: # 75-85%
|
||||
multiplier = 2.0
|
||||
|
||||
delay = self.base_delay * multiplier
|
||||
return min(delay, self.max_retry_delay)
|
||||
|
||||
async def wait_with_backoff(self, retry_count: int = 0):
|
||||
"""
|
||||
Wait with exponential backoff.
|
||||
|
||||
Args:
|
||||
retry_count: Current retry attempt (0-indexed)
|
||||
"""
|
||||
if retry_count == 0:
|
||||
# Normal delay based on throttle
|
||||
delay = self.get_throttle_delay()
|
||||
else:
|
||||
# Exponential backoff: 2^retry * base_delay
|
||||
delay = min(
|
||||
(2 ** retry_count) * self.base_delay,
|
||||
self.max_retry_delay
|
||||
)
|
||||
|
||||
if delay > self.base_delay:
|
||||
self.throttled_requests += 1
|
||||
max_usage = self.get_max_usage_pct()
|
||||
logger.info(f"⏸️ Throttling for {delay:.1f}s (max usage: {max_usage:.1f}%)")
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def execute_with_retry(
|
||||
self,
|
||||
func: Callable,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Execute API call with automatic retry and backoff.
|
||||
|
||||
Args:
|
||||
func: Function to execute (blocking, will be run in executor)
|
||||
*args: Positional arguments for func
|
||||
**kwargs: Keyword arguments for func
|
||||
|
||||
Returns:
|
||||
Result from func
|
||||
|
||||
Raises:
|
||||
Exception: If all retries exhausted
|
||||
"""
|
||||
self.total_requests += 1
|
||||
|
||||
for retry in range(self.max_retries):
|
||||
try:
|
||||
# Wait before request (with potential throttling)
|
||||
await self.wait_with_backoff(retry_count=retry if retry > 0 else 0)
|
||||
|
||||
# Execute request in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||
|
||||
# Update usage from response
|
||||
self.update_usage(result)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
# Check if it's a rate limit error (expanded list based on Meta docs)
|
||||
is_rate_limit = (
|
||||
'rate limit' in error_message or
|
||||
'too many requests' in error_message or
|
||||
'throttle' in error_message or
|
||||
'error code 4' in error_message or # App rate limit
|
||||
'error code 17' in error_message or # User rate limit
|
||||
'error code 32' in error_message or # Pages rate limit
|
||||
'error code 613' in error_message or # Custom rate limit
|
||||
'error code 80000' in error_message or # Ads Insights BUC
|
||||
'error code 80001' in error_message or # Pages BUC
|
||||
'error code 80002' in error_message or # Instagram BUC
|
||||
'error code 80003' in error_message or # Custom Audience BUC
|
||||
'error code 80004' in error_message or # Ads Management BUC
|
||||
'error code 80005' in error_message or # LeadGen BUC
|
||||
'error code 80006' in error_message or # Messenger BUC
|
||||
'error code 80008' in error_message or # WhatsApp BUC
|
||||
'error code 80009' in error_message or # Catalog Management BUC
|
||||
'error code 80014' in error_message # Catalog Batch BUC
|
||||
)
|
||||
|
||||
if is_rate_limit:
|
||||
self.rate_limit_errors += 1
|
||||
|
||||
if retry < self.max_retries - 1:
|
||||
backoff_delay = min(
|
||||
(2 ** (retry + 1)) * self.base_delay,
|
||||
self.max_retry_delay
|
||||
)
|
||||
logger.warning(f"🔄 Rate limit hit! Retrying in {backoff_delay:.1f}s (attempt {retry + 1}/{self.max_retries})")
|
||||
logger.warning(f" Error: {e}")
|
||||
await asyncio.sleep(backoff_delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"❌ Rate limit error - max retries exhausted: {e}")
|
||||
raise
|
||||
|
||||
# Not a rate limit error, re-raise immediately
|
||||
raise
|
||||
|
||||
# Should never reach here
|
||||
raise Exception("Max retries exhausted")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current rate limiter statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with comprehensive stats from all headers
|
||||
"""
|
||||
return {
|
||||
# Request stats
|
||||
'total_requests': self.total_requests,
|
||||
'throttled_requests': self.throttled_requests,
|
||||
'rate_limit_errors': self.rate_limit_errors,
|
||||
|
||||
# X-App-Usage metrics
|
||||
'app_call_count': self.app_call_count,
|
||||
'app_total_cputime': self.app_total_cputime,
|
||||
'app_total_time': self.app_total_time,
|
||||
|
||||
# X-Ad-Account-Usage metrics (per account)
|
||||
'ad_account_usage': self.ad_account_usage,
|
||||
|
||||
# X-Business-Use-Case-Usage metrics
|
||||
'buc_usage': self.buc_usage,
|
||||
'estimated_time_to_regain_access': self.estimated_time_to_regain_access,
|
||||
|
||||
# Legacy metrics
|
||||
'legacy_app_usage_pct': self.legacy_app_usage_pct,
|
||||
'legacy_account_usage_pct': self.legacy_account_usage_pct,
|
||||
|
||||
# Computed metrics
|
||||
'max_usage_pct': self.get_max_usage_pct(),
|
||||
'is_throttling': self.should_throttle(),
|
||||
}
|
||||
|
||||
def print_stats(self):
|
||||
"""Print current statistics with all rate limit metrics."""
|
||||
stats = self.get_stats()
|
||||
|
||||
output = []
|
||||
output.append("\n" + "="*70)
|
||||
output.append("RATE LIMITER STATISTICS")
|
||||
output.append("="*70)
|
||||
|
||||
# Request stats
|
||||
output.append(f"Total Requests: {stats['total_requests']}")
|
||||
output.append(f"Throttled Requests: {stats['throttled_requests']}")
|
||||
output.append(f"Rate Limit Errors: {stats['rate_limit_errors']}")
|
||||
output.append("")
|
||||
|
||||
# X-App-Usage
|
||||
output.append("X-App-Usage (Platform Rate Limits):")
|
||||
output.append(f" Call Count: {stats['app_call_count']:.1f}%")
|
||||
output.append(f" Total CPU Time: {stats['app_total_cputime']:.1f}%")
|
||||
output.append(f" Total Time: {stats['app_total_time']:.1f}%")
|
||||
output.append("")
|
||||
|
||||
# X-Ad-Account-Usage (per account)
|
||||
if stats['ad_account_usage']:
|
||||
# Only show accounts with data (skip "unknown" accounts with 0 usage)
|
||||
accounts_to_show = {
|
||||
account_id: usage
|
||||
for account_id, usage in stats['ad_account_usage'].items()
|
||||
if account_id != 'unknown' or usage.get('acc_id_util_pct', 0) > 0
|
||||
}
|
||||
|
||||
if accounts_to_show:
|
||||
output.append("X-Ad-Account-Usage (Per Account):")
|
||||
for account_id, usage in accounts_to_show.items():
|
||||
output.append(f" Account: {account_id}")
|
||||
output.append(f" Usage: {usage.get('acc_id_util_pct', 0):.1f}%")
|
||||
output.append(f" Reset Time: {usage.get('reset_time_duration', 0)}s")
|
||||
output.append(f" API Access Tier: {usage.get('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))
|
||||
File diff suppressed because it is too large
Load Diff
380
src/meta_api_grabber/setup_and_wait.py
Normal file
380
src/meta_api_grabber/setup_and_wait.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
View Manager Setup and Daemon
|
||||
|
||||
This script:
|
||||
1. Initializes database schema from db_schema.sql (drops and recreates public schema with all views)
|
||||
2. Loads metadata from metadata.yaml into the database
|
||||
3. Auto-detects materialized views from db_schema.sql
|
||||
4. Periodically refreshes materialized views (configurable interval)
|
||||
|
||||
Usage:
|
||||
uv run view-manager-setup
|
||||
|
||||
Environment variables:
|
||||
REFRESH_INTERVAL_MINUTES: How often to refresh materialized views (default: 60)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import yaml
|
||||
|
||||
from meta_api_grabber.database import TimescaleDBClient
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ViewManagerSetup:
|
||||
"""Manages database schema setup, metadata loading, and view refresh."""
|
||||
|
||||
def __init__(self, refresh_interval_minutes: int = 60):
|
||||
self.db = TimescaleDBClient()
|
||||
self.shutdown_event = asyncio.Event()
|
||||
self.refresh_interval_minutes = refresh_interval_minutes
|
||||
self.materialized_views: List[str] = []
|
||||
|
||||
async def load_metadata_from_yaml(self, yaml_path: Path):
|
||||
"""
|
||||
Load account metadata from YAML file and sync to database.
|
||||
|
||||
Args:
|
||||
yaml_path: Path to metadata.yaml file
|
||||
"""
|
||||
if not yaml_path.exists():
|
||||
logger.warning(f"Metadata file not found: {yaml_path}")
|
||||
logger.info("Skipping metadata loading. Create metadata.yaml to configure accounts.")
|
||||
return
|
||||
|
||||
logger.info(f"Loading metadata from {yaml_path}")
|
||||
|
||||
with open(yaml_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
accounts = data.get('accounts', [])
|
||||
if not accounts:
|
||||
logger.warning("No accounts found in metadata.yaml")
|
||||
return
|
||||
|
||||
# Sync accounts to database using upsert logic
|
||||
async with self.db.pool.acquire() as conn:
|
||||
# Clear existing metadata (or implement smarter sync logic)
|
||||
await conn.execute("DELETE FROM public.account_metadata")
|
||||
|
||||
for account in accounts:
|
||||
label = account.get('label')
|
||||
meta_account_id = account.get('meta_account_id')
|
||||
google_account_id = account.get('google_account_id')
|
||||
alpinebits_hotel_code = account.get('alpinebits_hotel_code')
|
||||
|
||||
if not label:
|
||||
logger.warning(f"Skipping account without label: {account}")
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO public.account_metadata
|
||||
(label, meta_account_id, google_account_id, alpinebits_hotel_code, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
""",
|
||||
label, meta_account_id, google_account_id, alpinebits_hotel_code
|
||||
)
|
||||
|
||||
logger.info(f"Loaded metadata for: {label}")
|
||||
|
||||
logger.info(f"Successfully loaded {len(accounts)} accounts from metadata.yaml")
|
||||
|
||||
async def load_geotargets_from_csv(self, csv_path: Path):
|
||||
"""
|
||||
Load geotargets data from CSV file and sync to database.
|
||||
|
||||
This function is idempotent - it will:
|
||||
1. Clear existing geotargets data
|
||||
2. Load fresh data from CSV
|
||||
|
||||
This means if you swap the CSV file, the data will be updated.
|
||||
|
||||
Args:
|
||||
csv_path: Path to geotargets CSV file
|
||||
"""
|
||||
if not csv_path.exists():
|
||||
logger.warning(f"Geotargets CSV file not found: {csv_path}")
|
||||
logger.info("Skipping geotargets loading. Place geotargets CSV file to load location data.")
|
||||
return
|
||||
|
||||
logger.info(f"Loading geotargets from {csv_path}")
|
||||
|
||||
# Read CSV file
|
||||
geotargets = []
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
# Convert IDs to integers, handling empty parent_id
|
||||
criteria_id = int(row['Criteria ID'])
|
||||
parent_id = int(row['Parent ID']) if row['Parent ID'] else None
|
||||
|
||||
geotargets.append({
|
||||
'criteria_id': criteria_id,
|
||||
'name': row['Name'],
|
||||
'canonical_name': row['Canonical Name'],
|
||||
'parent_id': parent_id,
|
||||
'country_code': row['Country Code'],
|
||||
'target_type': row['Target Type'],
|
||||
'status': row['Status']
|
||||
})
|
||||
|
||||
if not geotargets:
|
||||
logger.warning("No geotargets found in CSV file")
|
||||
return
|
||||
|
||||
# Load data to database using idempotent approach
|
||||
async with self.db.pool.acquire() as conn:
|
||||
# Clear existing data to ensure we have latest data from CSV
|
||||
deleted_count = await conn.fetchval("SELECT COUNT(*) FROM public.geotargets")
|
||||
await conn.execute("TRUNCATE TABLE public.geotargets")
|
||||
logger.info(f"Cleared {deleted_count} existing geotarget records")
|
||||
|
||||
# Batch insert new data
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO public.geotargets
|
||||
(criteria_id, name, canonical_name, parent_id, country_code, target_type, status, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
""",
|
||||
[(g['criteria_id'], g['name'], g['canonical_name'], g['parent_id'],
|
||||
g['country_code'], g['target_type'], g['status'])
|
||||
for g in geotargets]
|
||||
)
|
||||
|
||||
logger.info(f"Successfully loaded {len(geotargets)} geotargets from CSV")
|
||||
|
||||
def detect_materialized_views(self, schema_path: Path) -> List[str]:
|
||||
"""
|
||||
Auto-detect materialized views from db_schema.sql.
|
||||
|
||||
Parses the SQL file to find:
|
||||
1. CREATE MATERIALIZED VIEW statements
|
||||
2. REFRESH MATERIALIZED VIEW statements (explicit refresh requests)
|
||||
|
||||
Args:
|
||||
schema_path: Path to db_schema.sql
|
||||
|
||||
Returns:
|
||||
List of materialized view names (deduplicated, in order of appearance)
|
||||
"""
|
||||
if not schema_path.exists():
|
||||
logger.warning(f"Schema file not found: {schema_path}")
|
||||
return []
|
||||
|
||||
with open(schema_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
views = []
|
||||
|
||||
# Find CREATE MATERIALIZED VIEW statements
|
||||
# Matches: CREATE MATERIALIZED VIEW [IF NOT EXISTS] view_name AS
|
||||
create_pattern = r'CREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+AS'
|
||||
create_matches = re.findall(create_pattern, content, re.IGNORECASE)
|
||||
views.extend(create_matches)
|
||||
|
||||
# Find REFRESH MATERIALIZED VIEW statements
|
||||
# Matches: REFRESH MATERIALIZED VIEW [CONCURRENTLY] view_name
|
||||
refresh_pattern = r'REFRESH\s+MATERIALIZED\s+VIEW\s+(?:CONCURRENTLY\s+)?(?:public\.)?(\w+)'
|
||||
refresh_matches = re.findall(refresh_pattern, content, re.IGNORECASE)
|
||||
views.extend(refresh_matches)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
deduplicated = []
|
||||
for view in views:
|
||||
if view not in seen:
|
||||
seen.add(view)
|
||||
deduplicated.append(view)
|
||||
|
||||
if deduplicated:
|
||||
logger.info(f"Detected {len(deduplicated)} materialized view(s): {', '.join(deduplicated)}")
|
||||
else:
|
||||
logger.info("No materialized views detected in db_schema.sql")
|
||||
|
||||
return deduplicated
|
||||
|
||||
async def refresh_materialized_views(self):
|
||||
"""Refresh all detected materialized views."""
|
||||
if not self.materialized_views:
|
||||
logger.debug("No materialized views to refresh")
|
||||
return
|
||||
|
||||
logger.info(f"Refreshing {len(self.materialized_views)} materialized view(s)...")
|
||||
|
||||
for view_name in self.materialized_views:
|
||||
try:
|
||||
# Try CONCURRENTLY first (requires unique index)
|
||||
await self.db.pool.execute(
|
||||
f"REFRESH MATERIALIZED VIEW CONCURRENTLY public.{view_name}"
|
||||
)
|
||||
logger.info(f"✓ Refreshed {view_name} (CONCURRENTLY)")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
|
||||
# If CONCURRENTLY fails, try regular refresh
|
||||
if 'concurrently' in error_msg or 'unique index' in error_msg:
|
||||
try:
|
||||
await self.db.pool.execute(
|
||||
f"REFRESH MATERIALIZED VIEW public.{view_name}"
|
||||
)
|
||||
logger.info(f"✓ Refreshed {view_name}")
|
||||
except Exception as e2:
|
||||
logger.error(f"✗ Failed to refresh {view_name}: {e2}")
|
||||
else:
|
||||
logger.error(f"✗ Failed to refresh {view_name}: {e}")
|
||||
|
||||
logger.info("Materialized view refresh completed")
|
||||
|
||||
async def setup(self):
|
||||
"""Initialize database schema and load metadata."""
|
||||
try:
|
||||
# Connect to database
|
||||
await self.db.connect()
|
||||
|
||||
# Initialize schema (this will drop and recreate public schema)
|
||||
logger.info("Initializing database schema...")
|
||||
schema_path = Path(__file__).parent / "db_schema.sql"
|
||||
await self.db.initialize_schema()
|
||||
logger.info("Database schema initialized successfully")
|
||||
|
||||
# Auto-detect materialized views
|
||||
self.materialized_views = self.detect_materialized_views(schema_path)
|
||||
|
||||
# Load metadata from YAML
|
||||
metadata_path = Path(__file__).parent.parent.parent / "metadata.yaml"
|
||||
await self.load_metadata_from_yaml(metadata_path)
|
||||
|
||||
# Load geotargets from CSV
|
||||
# Look for the most recent geotargets CSV file in the package directory
|
||||
geotargets_dir = Path(__file__).parent
|
||||
geotargets_files = sorted(geotargets_dir.glob("geotargets*.csv"), reverse=True)
|
||||
if geotargets_files:
|
||||
geotargets_path = geotargets_files[0]
|
||||
await self.load_geotargets_from_csv(geotargets_path)
|
||||
else:
|
||||
logger.warning("No geotargets CSV file found in package directory")
|
||||
|
||||
# Initial refresh of materialized views
|
||||
if self.materialized_views:
|
||||
logger.info("Performing initial refresh of materialized views...")
|
||||
await self.refresh_materialized_views()
|
||||
|
||||
logger.info("Setup completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Setup failed: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def periodic_refresh_loop(self):
|
||||
"""
|
||||
Periodically refresh materialized views.
|
||||
|
||||
Runs in a loop until shutdown is signaled.
|
||||
"""
|
||||
if not self.materialized_views:
|
||||
logger.info("No materialized views to refresh, skipping periodic refresh")
|
||||
return
|
||||
|
||||
logger.info(f"Starting periodic refresh loop (interval: {self.refresh_interval_minutes} minutes)")
|
||||
|
||||
while not self.shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for the refresh interval or shutdown signal
|
||||
await asyncio.wait_for(
|
||||
self.shutdown_event.wait(),
|
||||
timeout=self.refresh_interval_minutes * 60
|
||||
)
|
||||
# If we get here, shutdown was signaled
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout means it's time to refresh
|
||||
logger.info("Starting scheduled materialized view refresh...")
|
||||
try:
|
||||
await self.refresh_materialized_views()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during scheduled refresh: {e}", exc_info=True)
|
||||
# Continue the loop even if refresh fails
|
||||
|
||||
async def wait_for_tasks(self):
|
||||
"""
|
||||
Run periodic tasks and wait for shutdown signal.
|
||||
|
||||
Currently runs:
|
||||
- Periodic materialized view refresh (configurable interval)
|
||||
|
||||
In the future, this could also:
|
||||
- Poll Airbyte API for completed syncs
|
||||
- Serve health check endpoints
|
||||
- Listen to webhooks
|
||||
"""
|
||||
logger.info("View manager is ready")
|
||||
logger.info("Press Ctrl+C to shutdown")
|
||||
|
||||
# Run periodic refresh loop
|
||||
await self.periodic_refresh_loop()
|
||||
|
||||
logger.info("Periodic refresh loop stopped")
|
||||
|
||||
async def run(self):
|
||||
"""Main entry point."""
|
||||
try:
|
||||
# Setup database and metadata
|
||||
await self.setup()
|
||||
|
||||
# Wait for tasks or shutdown
|
||||
await self.wait_for_tasks()
|
||||
|
||||
finally:
|
||||
logger.info("Shutting down...")
|
||||
await self.db.close()
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
def handle_shutdown(self, signum, frame):
|
||||
"""Handle shutdown signals gracefully."""
|
||||
logger.info(f"Received signal {signum}, initiating shutdown...")
|
||||
self.shutdown_event.set()
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
# Get refresh interval from environment variable
|
||||
refresh_interval = int(os.getenv('REFRESH_INTERVAL_MINUTES', '60'))
|
||||
logger.info(f"Configured refresh interval: {refresh_interval} minutes")
|
||||
|
||||
manager = ViewManagerSetup(refresh_interval_minutes=refresh_interval)
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, manager.handle_shutdown)
|
||||
signal.signal(signal.SIGTERM, manager.handle_shutdown)
|
||||
|
||||
# Run the async main function
|
||||
try:
|
||||
asyncio.run(manager.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Simple test script to initialize database and grab ad_accounts metadata.
|
||||
This is useful for testing the database setup and verifying ad account access.
|
||||
Grabs ALL ad accounts accessible to the token.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.user import User
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
from meta_api_grabber.database import TimescaleDBClient
|
||||
|
||||
|
||||
async def test_ad_accounts():
|
||||
"""Test database initialization and ad account metadata collection."""
|
||||
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("AD ACCOUNTS TEST - GRABBING ALL ACCESSIBLE ACCOUNTS")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
print("\nFetching all ad accounts accessible to this token...")
|
||||
me = User(fbid='me')
|
||||
account_fields = ['name', 'currency', 'timezone_name', 'account_status']
|
||||
|
||||
ad_accounts = me.get_ad_accounts(fields=account_fields)
|
||||
|
||||
print(f"Found {len(ad_accounts)} ad account(s)\n")
|
||||
|
||||
stored_count = 0
|
||||
for account in ad_accounts:
|
||||
account_id = account['id']
|
||||
print(f"Ad Account {stored_count + 1}:")
|
||||
print(f" ID: {account_id}")
|
||||
print(f" Name: {account.get('name', 'N/A')}")
|
||||
print(f" Currency: {account.get('currency', 'N/A')}")
|
||||
print(f" Timezone: {account.get('timezone_name', 'N/A')}")
|
||||
print(f" Status: {account.get('account_status', 'N/A')}")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
# Connect to database
|
||||
print("Connecting to database...")
|
||||
db = TimescaleDBClient()
|
||||
await db.connect()
|
||||
# Initialize schema
|
||||
print("\nInitializing database schema...")
|
||||
await db.initialize_schema()
|
||||
|
||||
# Get all ad accounts accessible to this token
|
||||
print("\nFetching all ad accounts accessible to this token...")
|
||||
me = User(fbid='me')
|
||||
account_fields = ['name', 'currency', 'timezone_name', 'account_status']
|
||||
|
||||
ad_accounts = me.get_ad_accounts(fields=account_fields)
|
||||
|
||||
print(f"Found {len(ad_accounts)} ad account(s)\n")
|
||||
|
||||
stored_count = 0
|
||||
for account in ad_accounts:
|
||||
account_id = account['id']
|
||||
print(f"Ad Account {stored_count + 1}:")
|
||||
print(f" ID: {account_id}")
|
||||
print(f" Name: {account.get('name', 'N/A')}")
|
||||
print(f" Currency: {account.get('currency', 'N/A')}")
|
||||
print(f" Timezone: {account.get('timezone_name', 'N/A')}")
|
||||
print(f" Status: {account.get('account_status', 'N/A')}")
|
||||
|
||||
# Store in database
|
||||
await db.upsert_ad_account(
|
||||
account_id=account_id,
|
||||
account_name=account.get('name'),
|
||||
currency=account.get('currency'),
|
||||
timezone_name=account.get('timezone_name'),
|
||||
)
|
||||
print(f" ✓ Stored in database\n")
|
||||
stored_count += 1
|
||||
|
||||
# Verify by querying the database
|
||||
print("="*60)
|
||||
print(f"Verifying database storage ({stored_count} account(s))...")
|
||||
print("="*60)
|
||||
async with db.pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT * FROM ad_accounts ORDER BY account_name")
|
||||
if rows:
|
||||
print(f"\n✓ {len(rows)} ad account(s) found in database:\n")
|
||||
for i, row in enumerate(rows, 1):
|
||||
print(f"{i}. {row['account_name']} ({row['account_id']})")
|
||||
print(f" Currency: {row['currency']} | Timezone: {row['timezone_name']}")
|
||||
print(f" Updated: {row['updated_at']}\n")
|
||||
else:
|
||||
print("❌ No ad accounts found in database")
|
||||
|
||||
print("="*60)
|
||||
print("TEST COMPLETED SUCCESSFULLY")
|
||||
print("="*60)
|
||||
print(f"\n✓ Successfully grabbed and stored {stored_count} ad account(s)")
|
||||
print("\nNext steps:")
|
||||
print("1. Check your database: docker exec meta_timescaledb psql -U meta_user -d meta_insights -c 'SELECT * FROM ad_accounts;'")
|
||||
print("2. Run scheduled grabber for all accounts: meta-scheduled")
|
||||
print("3. The scheduled grabber will now process all these accounts!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the test script."""
|
||||
exit_code = asyncio.run(test_ad_accounts())
|
||||
exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
Test to see what campaign insights API actually returns for campaign_name.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
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]):
|
||||
raise ValueError("Missing required environment variables")
|
||||
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
account_id = "act_238334370765317"
|
||||
|
||||
print(f"Testing campaign INSIGHTS fetch for account: {account_id}")
|
||||
print("=" * 60)
|
||||
|
||||
# Get campaign insights (this is what scheduled_grabber.py does)
|
||||
print("\nFetching campaign-level insights with 'today' preset...")
|
||||
print("-" * 60)
|
||||
|
||||
fields = [
|
||||
AdsInsights.Field.campaign_id,
|
||||
AdsInsights.Field.campaign_name,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpc,
|
||||
AdsInsights.Field.cpm,
|
||||
AdsInsights.Field.reach,
|
||||
AdsInsights.Field.actions,
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": "today",
|
||||
"level": "campaign",
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
ad_account = AdAccount(account_id)
|
||||
insights = ad_account.get_insights(
|
||||
fields=fields,
|
||||
params=params,
|
||||
)
|
||||
|
||||
insights_list = list(insights)
|
||||
print(f"Found {len(insights_list)} campaign insights\n")
|
||||
|
||||
campaigns_without_names = []
|
||||
for i, insight in enumerate(insights_list, 1):
|
||||
insight_dict = dict(insight)
|
||||
campaign_id = insight_dict.get('campaign_id')
|
||||
campaign_name = insight_dict.get('campaign_name')
|
||||
|
||||
print(f"Campaign Insight {i}:")
|
||||
print(f" Campaign ID: {campaign_id}")
|
||||
print(f" Campaign Name: {campaign_name if campaign_name else '❌ MISSING'}")
|
||||
print(f" Impressions: {insight_dict.get('impressions')}")
|
||||
print(f" Spend: {insight_dict.get('spend')}")
|
||||
print(f" Keys available: {list(insight_dict.keys())}")
|
||||
print(f" Raw JSON: {json.dumps(insight_dict, indent=4, default=str)}")
|
||||
print()
|
||||
|
||||
if not campaign_name:
|
||||
campaigns_without_names.append(campaign_id)
|
||||
|
||||
print("=" * 60)
|
||||
if campaigns_without_names:
|
||||
print(f"⚠️ {len(campaigns_without_names)} campaigns WITHOUT names in insights:")
|
||||
print(f" {campaigns_without_names}")
|
||||
else:
|
||||
print("✓ All campaigns have names in insights!")
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
Test script to diagnose campaign name issues for a specific ad account.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.campaign import Campaign
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
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]):
|
||||
raise ValueError("Missing required environment variables")
|
||||
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
# Test account ID
|
||||
account_id = "act_238334370765317"
|
||||
|
||||
print(f"Testing campaign fetch for account: {account_id}")
|
||||
print("=" * 60)
|
||||
|
||||
# Get campaigns using get_campaigns
|
||||
print("\n1. Testing AdAccount.get_campaigns()...")
|
||||
print("-" * 60)
|
||||
|
||||
ad_account = AdAccount(account_id)
|
||||
campaigns = ad_account.get_campaigns(
|
||||
fields=[
|
||||
Campaign.Field.name,
|
||||
Campaign.Field.status,
|
||||
Campaign.Field.objective,
|
||||
Campaign.Field.id,
|
||||
],
|
||||
params={'limit': 10}
|
||||
)
|
||||
|
||||
campaigns_list = list(campaigns)
|
||||
print(f"Found {len(campaigns_list)} campaigns\n")
|
||||
|
||||
campaigns_without_names = []
|
||||
for i, campaign in enumerate(campaigns_list, 1):
|
||||
campaign_dict = dict(campaign)
|
||||
campaign_id = campaign_dict.get('id')
|
||||
campaign_name = campaign_dict.get('name')
|
||||
|
||||
print(f"Campaign {i}:")
|
||||
print(f" ID: {campaign_id}")
|
||||
print(f" Name: {campaign_name if campaign_name else '❌ MISSING'}")
|
||||
print(f" Status: {campaign_dict.get('status')}")
|
||||
print(f" Objective: {campaign_dict.get('objective')}")
|
||||
print(f" Raw data: {json.dumps(campaign_dict, indent=4)}")
|
||||
print()
|
||||
|
||||
if not campaign_name:
|
||||
campaigns_without_names.append(campaign_id)
|
||||
|
||||
# If we found campaigns without names, try fetching them individually
|
||||
if campaigns_without_names:
|
||||
print("\n2. Retrying campaigns without names (individual fetch)...")
|
||||
print("-" * 60)
|
||||
|
||||
for campaign_id in campaigns_without_names:
|
||||
print(f"\nFetching campaign {campaign_id} directly...")
|
||||
try:
|
||||
campaign_obj = Campaign(campaign_id)
|
||||
campaign_data = campaign_obj.api_get(
|
||||
fields=[
|
||||
Campaign.Field.name,
|
||||
Campaign.Field.status,
|
||||
Campaign.Field.objective,
|
||||
Campaign.Field.account_id,
|
||||
]
|
||||
)
|
||||
|
||||
campaign_dict = dict(campaign_data)
|
||||
print(f" Direct fetch result:")
|
||||
print(f" Name: {campaign_dict.get('name', '❌ STILL MISSING')}")
|
||||
print(f" Status: {campaign_dict.get('status')}")
|
||||
print(f" Raw data: {json.dumps(campaign_dict, indent=4)}")
|
||||
except Exception as e:
|
||||
print(f" Error fetching campaign: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Summary: {len(campaigns_without_names)} out of {len(campaigns_list)} campaigns have missing names")
|
||||
print(f"Missing campaign IDs: {campaigns_without_names}")
|
||||
else:
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ All campaigns have names!")
|
||||
|
||||
print("\n3. Checking account permissions...")
|
||||
print("-" * 60)
|
||||
try:
|
||||
account_data = ad_account.api_get(
|
||||
fields=['name', 'account_status', 'capabilities', 'business']
|
||||
)
|
||||
account_dict = dict(account_data)
|
||||
print(f"Account Name: {account_dict.get('name')}")
|
||||
print(f"Account Status: {account_dict.get('account_status')}")
|
||||
print(f"Capabilities: {json.dumps(account_dict.get('capabilities', []), indent=2)}")
|
||||
print(f"Business ID: {account_dict.get('business')}")
|
||||
except Exception as e:
|
||||
print(f"Error fetching account details: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test complete!")
|
||||
@@ -1,125 +0,0 @@
|
||||
"""
|
||||
Test that date_start and date_stop are properly stored in the database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
from meta_api_grabber.database import TimescaleDBClient
|
||||
|
||||
|
||||
async def test_date_fields():
|
||||
"""Test that date fields are stored correctly."""
|
||||
load_dotenv()
|
||||
|
||||
access_token = os.getenv("META_ACCESS_TOKEN")
|
||||
app_secret = os.getenv("META_APP_SECRET")
|
||||
app_id = os.getenv("META_APP_ID")
|
||||
account_id = "act_238334370765317"
|
||||
|
||||
if not all([access_token, app_secret, app_id]):
|
||||
print("❌ Missing required environment variables")
|
||||
return 1
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
print("="*70)
|
||||
print("TESTING DATE_START AND DATE_STOP STORAGE")
|
||||
print("="*70)
|
||||
print()
|
||||
|
||||
# Connect to database
|
||||
print("Connecting to database...")
|
||||
db = TimescaleDBClient()
|
||||
await db.connect()
|
||||
|
||||
try:
|
||||
print("Fetching insights for 'today'...")
|
||||
ad_account = AdAccount(account_id)
|
||||
|
||||
fields = [
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": "today",
|
||||
"level": "account",
|
||||
}
|
||||
|
||||
insights = ad_account.get_insights(fields=fields, params=params)
|
||||
|
||||
# Store in database
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
for insight in insights:
|
||||
insight_dict = dict(insight)
|
||||
print(f"\nAPI Response:")
|
||||
print(f" date_start: {insight_dict.get('date_start')}")
|
||||
print(f" date_stop: {insight_dict.get('date_stop')}")
|
||||
print(f" impressions: {insight_dict.get('impressions')}")
|
||||
|
||||
await db.insert_account_insights(
|
||||
time=timestamp,
|
||||
account_id=account_id,
|
||||
data=insight_dict,
|
||||
date_preset="today",
|
||||
)
|
||||
print("\n✓ Stored in database")
|
||||
|
||||
# Verify from database
|
||||
print("\nQuerying database...")
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT date_start, date_stop, date_preset, impressions, spend
|
||||
FROM account_insights
|
||||
WHERE account_id = $1
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
""", account_id)
|
||||
|
||||
if row:
|
||||
print("\n✓ Retrieved from database:")
|
||||
print(f" date_start: {row['date_start']}")
|
||||
print(f" date_stop: {row['date_stop']}")
|
||||
print(f" date_preset: {row['date_preset']}")
|
||||
print(f" impressions: {row['impressions']}")
|
||||
print(f" spend: {row['spend']}")
|
||||
|
||||
print("\n" + "="*70)
|
||||
print("✓ TEST PASSED - Date fields are stored correctly!")
|
||||
print("="*70)
|
||||
print("\nYou can now query historical data by date_stop:")
|
||||
print(" - For clean daily trends, use: GROUP BY date_stop")
|
||||
print(" - For latest value per day, use: ORDER BY time DESC with date_stop")
|
||||
print()
|
||||
else:
|
||||
print("\n❌ No data found in database")
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(asyncio.run(test_date_fields()))
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,108 +0,0 @@
|
||||
"""
|
||||
Test to diagnose if the rate limiter is causing campaign name issues.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.campaign import Campaign
|
||||
|
||||
from rate_limiter import MetaRateLimiter
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
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]):
|
||||
raise ValueError("Missing required environment variables")
|
||||
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
account_id = "act_238334370765317"
|
||||
|
||||
# Initialize rate limiter
|
||||
rate_limiter = MetaRateLimiter(
|
||||
base_delay=0.1, # Fast for testing
|
||||
throttle_threshold=75.0,
|
||||
max_retry_delay=300.0,
|
||||
max_retries=5,
|
||||
)
|
||||
|
||||
|
||||
async def _rate_limited_request(func, *args, **kwargs):
|
||||
"""Execute a request with rate limiting (same as in scheduled_grabber.py)."""
|
||||
return await rate_limiter.execute_with_retry(func, *args, **kwargs)
|
||||
|
||||
|
||||
async def test_direct_fetch():
|
||||
"""Test direct fetch without rate limiter."""
|
||||
print("1. DIRECT FETCH (no rate limiter)")
|
||||
print("=" * 60)
|
||||
|
||||
ad_account = AdAccount(account_id)
|
||||
campaigns = ad_account.get_campaigns(
|
||||
fields=[
|
||||
Campaign.Field.name,
|
||||
Campaign.Field.status,
|
||||
Campaign.Field.objective,
|
||||
],
|
||||
params={'limit': 5}
|
||||
)
|
||||
|
||||
print(f"Type of campaigns: {type(campaigns)}")
|
||||
print(f"Campaigns object: {campaigns}\n")
|
||||
|
||||
for i, campaign in enumerate(campaigns, 1):
|
||||
campaign_dict = dict(campaign)
|
||||
print(f"Campaign {i}:")
|
||||
print(f" ID: {campaign_dict.get('id')}")
|
||||
print(f" Name: {campaign_dict.get('name', '❌ MISSING')}")
|
||||
print(f" Keys in dict: {list(campaign_dict.keys())}")
|
||||
print()
|
||||
|
||||
|
||||
async def test_rate_limited_fetch():
|
||||
"""Test fetch WITH rate limiter."""
|
||||
print("\n2. RATE LIMITED FETCH (with rate limiter)")
|
||||
print("=" * 60)
|
||||
|
||||
ad_account = AdAccount(account_id)
|
||||
campaigns = await _rate_limited_request(
|
||||
ad_account.get_campaigns,
|
||||
fields=[
|
||||
Campaign.Field.name,
|
||||
Campaign.Field.status,
|
||||
Campaign.Field.objective,
|
||||
],
|
||||
params={'limit': 5}
|
||||
)
|
||||
|
||||
print(f"Type of campaigns: {type(campaigns)}")
|
||||
print(f"Campaigns object: {campaigns}\n")
|
||||
|
||||
for i, campaign in enumerate(campaigns, 1):
|
||||
campaign_dict = dict(campaign)
|
||||
print(f"Campaign {i}:")
|
||||
print(f" ID: {campaign_dict.get('id')}")
|
||||
print(f" Name: {campaign_dict.get('name', '❌ MISSING')}")
|
||||
print(f" Keys in dict: {list(campaign_dict.keys())}")
|
||||
print()
|
||||
|
||||
|
||||
async def main():
|
||||
await test_direct_fetch()
|
||||
await test_rate_limited_fetch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Test script to check what date_start and date_stop look like for "today" preset.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from facebook_business.adobjects.adaccount import AdAccount
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights
|
||||
from facebook_business.api import FacebookAdsApi
|
||||
|
||||
|
||||
def test_today_preset():
|
||||
"""Test the 'today' date preset to see date_start and date_stop values."""
|
||||
load_dotenv()
|
||||
|
||||
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")
|
||||
return 1
|
||||
|
||||
# Initialize Facebook Ads API
|
||||
FacebookAdsApi.init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
# Use the first account we know exists
|
||||
account_id = "act_238334370765317"
|
||||
ad_account = AdAccount(account_id)
|
||||
|
||||
print("="*70)
|
||||
print("TESTING DATE_PRESET='TODAY'")
|
||||
print("="*70)
|
||||
print(f"Account: {account_id}")
|
||||
print()
|
||||
|
||||
# Request with date_preset="today"
|
||||
fields = [
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
|
||||
params = {
|
||||
"date_preset": "today",
|
||||
"level": "account",
|
||||
}
|
||||
|
||||
print("Making API request with date_preset='today'...")
|
||||
insights = ad_account.get_insights(fields=fields, params=params)
|
||||
|
||||
print("\nResponse:")
|
||||
print("-"*70)
|
||||
for insight in insights:
|
||||
insight_dict = dict(insight)
|
||||
print(json.dumps(insight_dict, indent=2))
|
||||
print()
|
||||
print("Key observations:")
|
||||
print(f" date_start: {insight_dict.get('date_start')}")
|
||||
print(f" date_stop: {insight_dict.get('date_stop')}")
|
||||
print(f" Are they the same? {insight_dict.get('date_start') == insight_dict.get('date_stop')}")
|
||||
print(f" impressions: {insight_dict.get('impressions')}")
|
||||
print(f" spend: {insight_dict.get('spend')}")
|
||||
|
||||
print()
|
||||
print("="*70)
|
||||
print("CONCLUSION")
|
||||
print("="*70)
|
||||
print("For 'today' preset:")
|
||||
print(" - date_start and date_stop should both be today's date")
|
||||
print(" - Metrics are cumulative from midnight to now")
|
||||
print(" - Multiple collections during the day will have same dates")
|
||||
print(" but increasing metric values")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(test_today_preset())
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
Token manager for automatic refresh of Meta access tokens.
|
||||
|
||||
Handles:
|
||||
- Loading token metadata
|
||||
- Checking token validity and expiry
|
||||
- Automatic refresh before expiry
|
||||
- Persistence of new tokens
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .auth import MetaOAuth2
|
||||
|
||||
|
||||
class MetaTokenManager:
|
||||
"""
|
||||
Manages Meta access tokens with automatic refresh.
|
||||
|
||||
Features:
|
||||
- Loads token from .env and metadata from .meta_token.json
|
||||
- Checks if token is expired or about to expire
|
||||
- Automatically refreshes tokens before expiry
|
||||
- Persists refreshed tokens
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_file: str = ".meta_token.json",
|
||||
refresh_before_days: int = 7,
|
||||
):
|
||||
"""
|
||||
Initialize token manager.
|
||||
|
||||
Args:
|
||||
token_file: Path to token metadata JSON file
|
||||
refresh_before_days: Refresh token this many days before expiry
|
||||
"""
|
||||
load_dotenv()
|
||||
|
||||
self.token_file = Path(token_file)
|
||||
self.refresh_before_days = refresh_before_days
|
||||
self.oauth = MetaOAuth2()
|
||||
|
||||
self._current_token: Optional[str] = None
|
||||
self._token_metadata: Optional[dict] = None
|
||||
|
||||
def load_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load access token from environment.
|
||||
|
||||
Returns:
|
||||
Access token or None
|
||||
"""
|
||||
return os.getenv("META_ACCESS_TOKEN")
|
||||
|
||||
def load_metadata(self) -> Optional[dict]:
|
||||
"""
|
||||
Load token metadata from JSON file.
|
||||
|
||||
Returns:
|
||||
Token metadata dict or None
|
||||
"""
|
||||
if not self.token_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(self.token_file.read_text())
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load token metadata: {e}")
|
||||
return None
|
||||
|
||||
def is_token_expired(self, metadata: dict) -> bool:
|
||||
"""
|
||||
Check if token is expired.
|
||||
|
||||
Args:
|
||||
metadata: Token metadata dictionary
|
||||
|
||||
Returns:
|
||||
True if expired
|
||||
"""
|
||||
expires_at = metadata.get("expires_at", 0)
|
||||
if not expires_at:
|
||||
return False
|
||||
|
||||
return time.time() >= expires_at
|
||||
|
||||
def should_refresh(self, metadata: dict) -> bool:
|
||||
"""
|
||||
Check if token should be refreshed.
|
||||
|
||||
Args:
|
||||
metadata: Token metadata dictionary
|
||||
|
||||
Returns:
|
||||
True if token should be refreshed
|
||||
"""
|
||||
expires_at = metadata.get("expires_at", 0)
|
||||
if not expires_at:
|
||||
return False
|
||||
|
||||
# Refresh if expiring within refresh_before_days
|
||||
threshold = time.time() + (self.refresh_before_days * 86400)
|
||||
return expires_at <= threshold
|
||||
|
||||
def refresh_token(self, current_token: str) -> str:
|
||||
"""
|
||||
Refresh token by exchanging for a new long-lived token.
|
||||
|
||||
Args:
|
||||
current_token: Current access token
|
||||
|
||||
Returns:
|
||||
New access token
|
||||
"""
|
||||
print("\n🔄 Refreshing access token...")
|
||||
|
||||
# Exchange current token for new long-lived token
|
||||
token_data = self.oauth.exchange_for_long_lived_token(current_token)
|
||||
new_token = token_data["access_token"]
|
||||
expires_in = token_data["expires_in"]
|
||||
|
||||
print(f"✅ Token refreshed! Valid for {expires_in / 86400:.0f} days")
|
||||
|
||||
# Get token info
|
||||
try:
|
||||
token_info = self.oauth.get_token_info(new_token)
|
||||
expires_at = token_info.get("expires_at", int(time.time()) + expires_in)
|
||||
is_valid = token_info.get("is_valid", True)
|
||||
except Exception:
|
||||
expires_at = int(time.time()) + expires_in
|
||||
is_valid = True
|
||||
|
||||
# Save new token
|
||||
self._save_token(new_token, expires_at, is_valid)
|
||||
|
||||
return new_token
|
||||
|
||||
def _save_token(self, access_token: str, expires_at: int, is_valid: bool):
|
||||
"""
|
||||
Save token to .env and metadata to JSON.
|
||||
|
||||
Args:
|
||||
access_token: New access token
|
||||
expires_at: Expiry timestamp
|
||||
is_valid: Whether token is valid
|
||||
"""
|
||||
# Update .env
|
||||
env_path = Path(".env")
|
||||
if env_path.exists():
|
||||
env_content = env_path.read_text()
|
||||
lines = env_content.split("\n")
|
||||
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("META_ACCESS_TOKEN="):
|
||||
lines[i] = f"META_ACCESS_TOKEN={access_token}"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
lines.append(f"META_ACCESS_TOKEN={access_token}")
|
||||
|
||||
env_path.write_text("\n".join(lines))
|
||||
|
||||
# Update metadata JSON
|
||||
metadata = {
|
||||
"access_token": access_token,
|
||||
"expires_at": expires_at,
|
||||
"issued_at": int(time.time()),
|
||||
"is_valid": is_valid,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
self.token_file.write_text(json.dumps(metadata, indent=2))
|
||||
|
||||
# Reload environment
|
||||
load_dotenv(override=True)
|
||||
|
||||
def get_valid_token(self) -> str:
|
||||
"""
|
||||
Get a valid access token, refreshing if necessary.
|
||||
|
||||
Returns:
|
||||
Valid access token
|
||||
|
||||
Raises:
|
||||
ValueError: If no token available or refresh fails
|
||||
"""
|
||||
# Load token and metadata
|
||||
token = self.load_token()
|
||||
metadata = self.load_metadata()
|
||||
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"No access token found. Run 'uv run python src/meta_api_grabber/auth.py' to authenticate."
|
||||
)
|
||||
|
||||
# If no metadata, assume token is valid (but warn)
|
||||
if not metadata:
|
||||
print("⚠️ Warning: No token metadata found. Cannot check expiry.")
|
||||
print(" Run 'uv run python src/meta_api_grabber/auth.py' to re-authenticate and save metadata.")
|
||||
return token
|
||||
|
||||
# Check if expired
|
||||
if self.is_token_expired(metadata):
|
||||
print("❌ Token expired! Attempting to refresh...")
|
||||
try:
|
||||
return self.refresh_token(token)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Token expired and refresh failed: {e}\n"
|
||||
"Please re-authenticate: uv run python src/meta_api_grabber/auth.py"
|
||||
)
|
||||
|
||||
# Check if should refresh (within threshold)
|
||||
if self.should_refresh(metadata):
|
||||
expires_at = metadata.get("expires_at", 0)
|
||||
expires_dt = datetime.fromtimestamp(expires_at)
|
||||
days_left = (expires_dt - datetime.now()).days
|
||||
|
||||
print(f"\n⚠️ Token expiring in {days_left} days ({expires_dt.strftime('%Y-%m-%d')})")
|
||||
print(f" Refreshing token to extend validity...")
|
||||
|
||||
try:
|
||||
return self.refresh_token(token)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Token refresh failed: {e}")
|
||||
print(f" Continuing with current token ({days_left} days remaining)")
|
||||
return token
|
||||
|
||||
# Token is valid
|
||||
expires_at = metadata.get("expires_at", 0)
|
||||
if expires_at:
|
||||
expires_dt = datetime.fromtimestamp(expires_at)
|
||||
days_left = (expires_dt - datetime.now()).days
|
||||
print(f"✅ Token valid ({days_left} days remaining)")
|
||||
|
||||
return token
|
||||
|
||||
def print_token_status(self):
|
||||
"""Print current token status."""
|
||||
token = self.load_token()
|
||||
metadata = self.load_metadata()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("TOKEN STATUS")
|
||||
print("="*60)
|
||||
|
||||
if not token:
|
||||
print("❌ No access token found in .env")
|
||||
print("\nRun: uv run python src/meta_api_grabber/auth.py")
|
||||
return
|
||||
|
||||
print("✅ Access token found")
|
||||
|
||||
if not metadata:
|
||||
print("⚠️ No metadata file (.meta_token.json)")
|
||||
print(" Cannot determine expiry. Re-authenticate to save metadata.")
|
||||
return
|
||||
|
||||
expires_at = metadata.get("expires_at", 0)
|
||||
is_valid = metadata.get("is_valid", False)
|
||||
|
||||
if expires_at:
|
||||
expires_dt = datetime.fromtimestamp(expires_at)
|
||||
days_left = (expires_dt - datetime.now()).days
|
||||
|
||||
print(f"\nExpires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"Days remaining: {days_left}")
|
||||
|
||||
if days_left < 0:
|
||||
print("Status: ❌ EXPIRED")
|
||||
elif days_left <= self.refresh_before_days:
|
||||
print(f"Status: ⚠️ EXPIRING SOON (will auto-refresh)")
|
||||
else:
|
||||
print("Status: ✅ VALID")
|
||||
|
||||
print(f"Is valid: {'✅' if is_valid else '❌'}")
|
||||
print("="*60 + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""Check token status and refresh if needed."""
|
||||
manager = MetaTokenManager()
|
||||
manager.print_token_status()
|
||||
|
||||
try:
|
||||
token = manager.get_valid_token()
|
||||
print(f"\n✅ Valid token ready for use")
|
||||
except ValueError as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
View manager for TimescaleDB materialized views.
|
||||
Handles creation, updates, and refresh of materialized views for flattened insights data.
|
||||
Views are loaded from individual SQL files in the views directory.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import List, Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ViewManager:
|
||||
"""Manages materialized views for insights data flattening."""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool):
|
||||
"""
|
||||
Initialize view manager with a database connection pool.
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool
|
||||
"""
|
||||
self.pool = pool
|
||||
self.views_dir = pathlib.Path(__file__).parent / "views"
|
||||
|
||||
async def initialize_views(self) -> None:
|
||||
"""
|
||||
Initialize all materialized views at startup.
|
||||
Loads and executes SQL files from the views directory in alphabetical order.
|
||||
Creates views if they don't exist, idempotent operation.
|
||||
"""
|
||||
logger.info("Initializing materialized views...")
|
||||
|
||||
if not self.views_dir.exists():
|
||||
logger.warning(f"Views directory not found at {self.views_dir}")
|
||||
return
|
||||
|
||||
# Get all .sql files in alphabetical order for consistent execution
|
||||
view_files = sorted(self.views_dir.glob("*.sql"))
|
||||
if not view_files:
|
||||
logger.warning(f"No SQL files found in {self.views_dir}")
|
||||
return
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
for view_file in view_files:
|
||||
logger.debug(f"Loading view file: {view_file.name}")
|
||||
await self._execute_view_file(conn, view_file)
|
||||
|
||||
logger.info("✓ Materialized views initialized successfully")
|
||||
|
||||
async def _execute_view_file(self, conn: asyncpg.Connection, view_file: pathlib.Path) -> None:
|
||||
"""
|
||||
Execute SQL statements from a view file.
|
||||
|
||||
Args:
|
||||
conn: asyncpg connection
|
||||
view_file: Path to SQL file
|
||||
"""
|
||||
with open(view_file, 'r') as f:
|
||||
view_sql = f.read()
|
||||
|
||||
statements = [s.strip() for s in view_sql.split(';') if s.strip()]
|
||||
|
||||
for i, stmt in enumerate(statements, 1):
|
||||
if not stmt:
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(stmt)
|
||||
logger.debug(f"{view_file.name}: Executed statement {i}")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "does not exist" in error_msg:
|
||||
# Could be a missing dependent view or table, log it
|
||||
logger.debug(f"{view_file.name}: View or table does not exist (statement {i})")
|
||||
else:
|
||||
# Log other errors but don't fail - could be incompatible schema changes
|
||||
logger.warning(f"{view_file.name}: Error in statement {i}: {e}")
|
||||
|
||||
async def refresh_views(self, view_names: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Refresh specified materialized views.
|
||||
|
||||
Args:
|
||||
view_names: List of view names to refresh. If None, refreshes all views.
|
||||
"""
|
||||
if view_names is None:
|
||||
view_names = [
|
||||
"adset_insights_flattened",
|
||||
"account_insights_flattened",
|
||||
"campaign_insights_flattened",
|
||||
]
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
for view_name in view_names:
|
||||
try:
|
||||
# Use CONCURRENTLY to avoid locking
|
||||
await conn.execute(
|
||||
f"REFRESH MATERIALIZED VIEW CONCURRENTLY {view_name};"
|
||||
)
|
||||
logger.debug(f"Refreshed materialized view: {view_name}")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# View might not exist if not initialized, that's okay
|
||||
if "does not exist" in error_msg:
|
||||
logger.debug(f"View does not exist, skipping refresh: {view_name}")
|
||||
else:
|
||||
logger.warning(f"Error refreshing view {view_name}: {e}")
|
||||
|
||||
async def refresh_all_views(self) -> None:
|
||||
"""Refresh all materialized views."""
|
||||
await self.refresh_views()
|
||||
25
src/meta_api_grabber/views/SETUP_PERMISSIONS.md
Normal file
25
src/meta_api_grabber/views/SETUP_PERMISSIONS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# View Permissions Setup
|
||||
|
||||
The scheduled grabber needs the `meta_user` to have permissions to create/drop/modify views.
|
||||
|
||||
## One-time Setup (run as superuser or database owner)
|
||||
|
||||
```sql
|
||||
-- Give meta_user the ability to create views in the public schema
|
||||
GRANT CREATE ON SCHEMA public TO meta_user;
|
||||
|
||||
-- Alternative: Make meta_user the owner of all views (if they already exist)
|
||||
-- ALTER MATERIALIZED VIEW account_insights_flattened OWNER TO meta_user;
|
||||
-- ALTER MATERIALIZED VIEW campaign_insights_flattened OWNER TO meta_user;
|
||||
-- ALTER MATERIALIZED VIEW adset_insights_flattened OWNER TO meta_user;
|
||||
```
|
||||
|
||||
Run these commands once as a superuser/database owner, then the scheduled grabber can manage views normally.
|
||||
|
||||
## Why This Is Needed
|
||||
|
||||
PostgreSQL materialized views must be owned by the user who created them. Since the scheduled grabber recreates views on startup (to apply schema changes), it needs permission to:
|
||||
- `DROP MATERIALIZED VIEW` - remove old views
|
||||
- `CREATE MATERIALIZED VIEW` - create new views
|
||||
|
||||
Without proper schema permissions, the `meta_user` cannot perform these operations.
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR REPLACE MATERIALIZED VIEW account_insights_flattened AS
|
||||
DROP MATERIALIZED VIEW IF EXISTS account_insights_flattened CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW account_insights_flattened AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
|
||||
20
src/meta_api_grabber/views/account_insights_by_device.sql
Normal file
20
src/meta_api_grabber/views/account_insights_by_device.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
--- account insights by gender
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_device CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_device AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
device_platform,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_device_flattened
|
||||
GROUP BY time, account_id, device_platform;
|
||||
|
||||
|
||||
53
src/meta_api_grabber/views/account_insights_by_gender.sql
Normal file
53
src/meta_api_grabber/views/account_insights_by_gender.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_gender CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_gender AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
gender,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_gender
|
||||
GROUP BY time, account_id, gender;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_age CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_age AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
age,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_age
|
||||
GROUP BY time, account_id, age;
|
||||
|
||||
DROP VIEW IF EXISTS account_insights_by_gender_and_age CASCADE;
|
||||
|
||||
CREATE VIEW account_insights_by_gender_and_age AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
gender,
|
||||
age,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
SUM(link_click) AS link_click,
|
||||
SUM(landing_page_view) AS landing_page_view,
|
||||
SUM(lead) AS lead
|
||||
FROM campaign_insights_by_gender_and_age
|
||||
GROUP BY time, account_id, age, gender;
|
||||
|
||||
55
src/meta_api_grabber/views/account_insights_google.sql
Normal file
55
src/meta_api_grabber/views/account_insights_google.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
DROP VIEW IF EXISTS g_account_insights CASCADE;
|
||||
CREATE VIEW g_account_insights AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
clicks,
|
||||
impressions,
|
||||
interactions,
|
||||
cost_micros,
|
||||
cost_micros / 1000000.0 as cost,
|
||||
leads,
|
||||
engagements,
|
||||
customer_currency_code,
|
||||
account_name,
|
||||
|
||||
-- CTR (Click-Through Rate)
|
||||
(clicks::numeric / impressions_nz) * 100 as ctr,
|
||||
|
||||
-- CPM (Cost Per Mille) in micros and standard units
|
||||
(cost_micros::numeric / impressions_nz) * 1000 as cpm_micros,
|
||||
(cost_micros::numeric / impressions_nz) * 1000 / 1000000.0 as cpm,
|
||||
|
||||
-- CPC (Cost Per Click) in micros and standard units
|
||||
cost_micros::numeric / clicks_nz as cpc_micros,
|
||||
cost_micros::numeric / clicks_nz / 1000000.0 as cpc,
|
||||
|
||||
-- CPL (Cost Per Lead) in micros and standard units
|
||||
cost_micros::numeric / leads_nz as cpl_micros,
|
||||
cost_micros::numeric / leads_nz / 1000000.0 as cpl,
|
||||
|
||||
-- Conversion Rate
|
||||
(leads::numeric / clicks_nz) * 100 as conversion_rate,
|
||||
|
||||
-- Engagement Rate
|
||||
(engagements::numeric / impressions_nz) * 100 as engagement_rate
|
||||
|
||||
FROM (
|
||||
SELECT
|
||||
segments_date as time,
|
||||
customer_id as account_id,
|
||||
sum(metrics_clicks) as clicks,
|
||||
sum(metrics_impressions) as impressions,
|
||||
sum(metrics_interactions) as interactions,
|
||||
sum(metrics_cost_micros) as cost_micros,
|
||||
sum(metrics_conversions) as leads,
|
||||
sum(metrics_engagements) as engagements,
|
||||
customer_currency_code,
|
||||
customer_descriptive_name as account_name,
|
||||
-- Null-safe denominators
|
||||
NULLIF(sum(metrics_clicks), 0) as clicks_nz,
|
||||
NULLIF(sum(metrics_impressions), 0) as impressions_nz,
|
||||
NULLIF(sum(metrics_conversions), 0) as leads_nz
|
||||
FROM google.account_performance_report
|
||||
GROUP BY account_id, time, customer_currency_code, account_name
|
||||
) base;
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR REPLACE MATERIALIZED VIEW adset_insights_flattened AS
|
||||
DROP MATERIALIZED VIEW IF EXISTS adset_insights_flattened CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW adset_insights_flattened AS
|
||||
SELECT
|
||||
time,
|
||||
adset_id,
|
||||
@@ -34,3 +36,21 @@ CREATE INDEX idx_adset_insights_flat_date ON adset_insights_flattened(date_start
|
||||
|
||||
CREATE UNIQUE INDEX idx_adset_insights_flat_unique ON adset_insights_flattened(time, adset_id);
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY adset_insights_flattened;
|
||||
|
||||
|
||||
SELECT
|
||||
date_start as time,
|
||||
account_id,
|
||||
adset_id,
|
||||
campaign_id,
|
||||
SUM(impressions) AS impressions,
|
||||
SUM(clicks) AS clicks,
|
||||
SUM(spend) AS spend,
|
||||
AVG(frequency) as frequency,
|
||||
avg(cpc) as cpc,
|
||||
avg(cpm) as cpm,
|
||||
avg(cpp) as cpp,
|
||||
avg(ctr) as ctr
|
||||
|
||||
FROM meta.ads_insights
|
||||
group by time, account_id, adset_id, campaign_id
|
||||
@@ -1,9 +1,10 @@
|
||||
--- campaign insights
|
||||
|
||||
CREATE OR REPLACE MATERIALIZED VIEW campaign_insights_flattened AS
|
||||
SELECT
|
||||
time,
|
||||
account_id,
|
||||
DROP MATERIALIZED VIEW IF EXISTS campaign_insights_flattened CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW campaign_insights_flattened AS
|
||||
SELECT date_start AS "time",
|
||||
concat('act_', account_id) AS account_id,
|
||||
campaign_id,
|
||||
impressions,
|
||||
clicks,
|
||||
@@ -12,22 +13,18 @@ SELECT
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
date_preset,
|
||||
date_start,
|
||||
date_stop,
|
||||
fetched_at,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
|
||||
FROM campaign_insights;
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'link_click'::text) AS link_click,
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'landing_page_view'::text) AS landing_page_view,
|
||||
( SELECT (jsonb_array_elements.value ->> 'value'::text)::numeric AS "numeric"
|
||||
FROM jsonb_array_elements(customcampaign_insights.actions) jsonb_array_elements(value)
|
||||
WHERE (jsonb_array_elements.value ->> 'action_type'::text) = 'lead'::text) AS lead
|
||||
FROM meta.customcampaign_insights;
|
||||
|
||||
CREATE INDEX idx_campaign_insights_flat_date ON campaign_insights_flattened(date_start, date_stop);
|
||||
|
||||
|
||||
36
src/meta_api_grabber/views/campaign_insights_by_country.sql
Normal file
36
src/meta_api_grabber/views/campaign_insights_by_country.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
--- campaign insights by country
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS campaign_insights_by_country_flattened CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW campaign_insights_by_country_flattened AS
|
||||
SELECT date_start AS "time",
|
||||
concat('act_', account_id) AS account_id,
|
||||
campaign_id,
|
||||
country,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
date_start,
|
||||
date_stop,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_country;
|
||||
|
||||
CREATE INDEX idx_campaign_insights_by_country_flat_date ON campaign_insights_by_country_flattened(date_start, date_stop);
|
||||
|
||||
CREATE UNIQUE INDEX idx_campaign_insights_by_country_flat_unique ON campaign_insights_by_country_flattened(time, campaign_id, country);
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY campaign_insights_by_country_flattened;
|
||||
32
src/meta_api_grabber/views/campaign_insights_by_device.sql
Normal file
32
src/meta_api_grabber/views/campaign_insights_by_device.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
--- campaign insights by device
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS campaign_insights_by_device_flattened CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW campaign_insights_by_device_flattened AS
|
||||
SELECT date_start AS "time",
|
||||
concat('act_', account_id) AS account_id,
|
||||
campaign_id,
|
||||
device_platform,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
date_start,
|
||||
date_stop,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_device;
|
||||
|
||||
CREATE INDEX idx_campaign_insights_by_device_flat_date ON campaign_insights_by_device_flattened(date_start, date_stop);
|
||||
|
||||
CREATE UNIQUE INDEX idx_campaign_insights_by_device_flat_unique ON campaign_insights_by_device_flattened(time, campaign_id, device_platform);
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY campaign_insights_by_device_flattened;
|
||||
71
src/meta_api_grabber/views/campaign_insights_by_gender.sql
Normal file
71
src/meta_api_grabber/views/campaign_insights_by_gender.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
--- campaign insights by country
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS campaign_insights_by_gender_and_age CASCADE;
|
||||
|
||||
CREATE MATERIALIZED VIEW campaign_insights_by_gender_and_age AS
|
||||
SELECT date_start AS "time",
|
||||
concat('act_', account_id) AS account_id,
|
||||
campaign_id,
|
||||
gender,
|
||||
age,
|
||||
impressions,
|
||||
clicks,
|
||||
spend,
|
||||
reach,
|
||||
frequency,
|
||||
ctr,
|
||||
cpc,
|
||||
cpm,
|
||||
date_start,
|
||||
date_stop,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'link_click') AS link_click,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'landing_page_view') AS landing_page_view,
|
||||
(SELECT (value->>'value')::numeric
|
||||
FROM jsonb_array_elements(actions)
|
||||
WHERE value->>'action_type' = 'lead') AS lead
|
||||
|
||||
FROM meta.custom_campaign_gender;
|
||||
|
||||
CREATE INDEX idx_campaign_insights_by_gender_and_age_date ON campaign_insights_by_gender_and_age(date_start, date_stop);
|
||||
|
||||
CREATE UNIQUE INDEX idx_campaign_insights_by_gender_and_age_unique ON campaign_insights_by_gender_and_age(time, campaign_id, gender, age);
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY campaign_insights_by_gender_and_age;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_gender CASCADE;
|
||||
|
||||
create view campaign_insights_by_gender as
|
||||
Select time,
|
||||
sum(clicks) as clicks,
|
||||
sum(link_click) as link_click,
|
||||
sum(lead) as lead,
|
||||
sum(landing_page_view) as landing_page_view,
|
||||
sum(spend) as spend,
|
||||
sum(reach) as reach,
|
||||
sum(impressions) as impressions,
|
||||
gender,
|
||||
campaign_id,
|
||||
account_id
|
||||
from campaign_insights_by_gender_and_age
|
||||
group by time, gender, account_id, campaign_id, date_start, date_stop;
|
||||
|
||||
DROP VIEW IF EXISTS campaign_insights_by_age CASCADE;
|
||||
|
||||
create view campaign_insights_by_age as
|
||||
Select time,
|
||||
sum(clicks) as clicks,
|
||||
sum(link_click) as link_click,
|
||||
sum(lead) as lead,
|
||||
sum(landing_page_view) as landing_page_view,
|
||||
sum(spend) as spend,
|
||||
sum(reach) as reach,
|
||||
sum(impressions) as impressions,
|
||||
age,
|
||||
campaign_id,
|
||||
account_id
|
||||
from campaign_insights_by_gender_and_age
|
||||
group by time, age, account_id, campaign_id, date_start, date_stop;
|
||||
115
tests/README.md
Normal file
115
tests/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Tests
|
||||
|
||||
This directory contains tests for the meta_api_grabber project.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Install test dependencies:
|
||||
```bash
|
||||
uv sync --extra test
|
||||
```
|
||||
|
||||
Run all tests:
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Run specific test file:
|
||||
```bash
|
||||
uv run pytest tests/test_field_schema_validation.py -v
|
||||
```
|
||||
|
||||
Run with verbose output:
|
||||
```bash
|
||||
uv run pytest tests/test_field_schema_validation.py -v -s
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
### `test_field_schema_validation.py` (Integration Test)
|
||||
|
||||
This is a critical integration test that validates all fields requested by the grab_* methods in `scheduled_grabber.py` exist in the actual database schema.
|
||||
|
||||
**What it does:**
|
||||
1. Parses `db_schema.sql` to extract actual table columns
|
||||
2. Checks fields requested by each grab method:
|
||||
- `grab_account_insights()` → `account_insights` table
|
||||
- `grab_campaign_insights()` → `campaign_insights` table
|
||||
- `grab_adset_insights()` → `adset_insights` table
|
||||
- `grab_campaign_insights_by_country()` → `campaign_insights_by_country` table
|
||||
3. Verifies all requested fields exist in the corresponding database table
|
||||
|
||||
**Why this test is important:** When new fields are added to the Meta API field lists, this test quickly alerts you if the corresponding database columns need to be added. Since fields are only added (never removed), the test helps catch schema mismatches early.
|
||||
|
||||
**Test methods:**
|
||||
- `test_account_insights_fields()` - Validates account-level insight fields
|
||||
- `test_campaign_insights_fields()` - Validates campaign-level insight fields
|
||||
- `test_adset_insights_fields()` - Validates ad set-level insight fields
|
||||
- `test_campaign_insights_by_country_fields()` - Validates country breakdown fields
|
||||
- `test_all_tables_exist()` - Ensures all required insight tables exist
|
||||
- `test_schema_documentation()` - Prints out the parsed schema for reference
|
||||
|
||||
**Output example:**
|
||||
```
|
||||
Table: account_insights
|
||||
Columns (17): account_id, actions, clicks, cost_per_action_type, cpc, cpm, cpp, ctr, ...
|
||||
|
||||
Table: campaign_insights
|
||||
Columns (15): account_id, actions, campaign_id, clicks, cpc, cpm, ctr, ...
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Use markers to categorize tests:
|
||||
```python
|
||||
@pytest.mark.unit
|
||||
def test_something():
|
||||
pass
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_database_connection():
|
||||
pass
|
||||
```
|
||||
|
||||
Run only unit tests:
|
||||
```bash
|
||||
uv run pytest -m unit
|
||||
```
|
||||
|
||||
Run everything except integration tests:
|
||||
```bash
|
||||
uv run pytest -m "not integration"
|
||||
```
|
||||
|
||||
## Schema Validation Workflow
|
||||
|
||||
When you add new fields to a grab method:
|
||||
|
||||
1. **Add fields to `scheduled_grabber.py`:**
|
||||
```python
|
||||
fields = [
|
||||
...
|
||||
AdsInsights.Field.new_field, # New field added
|
||||
]
|
||||
```
|
||||
|
||||
2. **Run tests to see what's missing:**
|
||||
```bash
|
||||
uv run pytest tests/test_field_schema_validation.py -v -s
|
||||
```
|
||||
|
||||
3. **Test output will show:**
|
||||
```
|
||||
adset_insights table missing columns: {'new_field'}
|
||||
Available: [account_id, actions, adset_id, ...]
|
||||
```
|
||||
|
||||
4. **Update `db_schema.sql` with the new column:**
|
||||
```sql
|
||||
ALTER TABLE adset_insights ADD COLUMN IF NOT EXISTS new_field TYPE;
|
||||
```
|
||||
|
||||
5. **Run tests again to verify:**
|
||||
```bash
|
||||
uv run pytest tests/test_field_schema_validation.py -v
|
||||
```
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for meta_api_grabber package."""
|
||||
13
tests/conftest.py
Normal file
13
tests/conftest.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Pytest configuration and fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest."""
|
||||
config.addinivalue_line(
|
||||
"markers", "integration: marks tests as integration tests (deselect with '-m \"not integration\"')"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "unit: marks tests as unit tests"
|
||||
)
|
||||
360
tests/test_field_schema_validation.py
Normal file
360
tests/test_field_schema_validation.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
Integration test that validates all fields requested by grab_* methods exist in the database schema.
|
||||
|
||||
This test:
|
||||
1. Parses the SQL schema file (db_schema.sql) to extract actual table columns
|
||||
2. Reads scheduled_grabber.py to find which methods call which tables
|
||||
3. Verifies that all requested fields exist in the actual database schema
|
||||
"""
|
||||
|
||||
import re
|
||||
import pathlib
|
||||
from typing import Dict, Set, List
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def parse_sql_schema() -> Dict[str, Set[str]]:
|
||||
"""
|
||||
Parse db_schema.sql to extract table columns.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to sets of column names
|
||||
"""
|
||||
schema_file = pathlib.Path(__file__).parent.parent / "src" / "meta_api_grabber" / "db_schema.sql"
|
||||
|
||||
if not schema_file.exists():
|
||||
raise FileNotFoundError(f"Schema file not found: {schema_file}")
|
||||
|
||||
with open(schema_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
tables = {}
|
||||
|
||||
# Parse CREATE TABLE statements
|
||||
# Pattern: CREATE TABLE IF NOT EXISTS table_name (...)
|
||||
create_table_pattern = r'CREATE TABLE IF NOT EXISTS (\w+)\s*\((.*?)\);'
|
||||
|
||||
for match in re.finditer(create_table_pattern, content, re.DOTALL):
|
||||
table_name = match.group(1)
|
||||
table_body = match.group(2)
|
||||
|
||||
# Extract column names (first word before space/comma)
|
||||
# Pattern: column_name TYPE ...
|
||||
column_pattern = r'^\s*(\w+)\s+\w+'
|
||||
columns = set()
|
||||
|
||||
for line in table_body.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('--') or line.startswith('PRIMARY') or line.startswith('FOREIGN') or line.startswith('CONSTRAINT'):
|
||||
continue
|
||||
|
||||
col_match = re.match(column_pattern, line)
|
||||
if col_match:
|
||||
columns.add(col_match.group(1))
|
||||
|
||||
if columns:
|
||||
tables[table_name] = columns
|
||||
|
||||
return tables
|
||||
|
||||
|
||||
def get_field_name(field_str: str) -> str:
|
||||
"""
|
||||
Extract field name from AdsInsights.Field.xxx notation.
|
||||
|
||||
Example: 'impressions' from 'AdsInsights.Field.impressions'
|
||||
"""
|
||||
if '.' in field_str:
|
||||
return field_str.split('.')[-1]
|
||||
return field_str
|
||||
|
||||
|
||||
def extract_fields_from_grabber_source() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Extract field lists from grab_* methods by reading scheduled_grabber.py source.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping method names to lists of field names
|
||||
"""
|
||||
grabber_file = pathlib.Path(__file__).parent.parent / "src" / "meta_api_grabber" / "scheduled_grabber.py"
|
||||
|
||||
if not grabber_file.exists():
|
||||
raise FileNotFoundError(f"scheduled_grabber.py not found: {grabber_file}")
|
||||
|
||||
with open(grabber_file, 'r') as f:
|
||||
source = f.read()
|
||||
|
||||
methods_to_table = {
|
||||
'grab_account_insights': 'account_insights',
|
||||
'grab_campaign_insights': 'campaign_insights',
|
||||
'grab_adset_insights': 'adset_insights',
|
||||
'grab_campaign_insights_by_country': 'campaign_insights_by_country',
|
||||
}
|
||||
|
||||
result = {}
|
||||
|
||||
for method_name in methods_to_table.keys():
|
||||
# Find the method definition by looking for: async def method_name(...)
|
||||
method_pattern = rf'async def {method_name}\s*\('
|
||||
method_match = re.search(method_pattern, source)
|
||||
|
||||
if not method_match:
|
||||
continue
|
||||
|
||||
# Get the position after the method name pattern
|
||||
start_pos = method_match.end()
|
||||
|
||||
# Now find where the method body actually starts (after the closing paren and docstring)
|
||||
# Skip to the opening paren
|
||||
open_paren_pos = start_pos - 1
|
||||
|
||||
# Count parentheses to find the closing paren of the function signature
|
||||
paren_count = 1
|
||||
pos = open_paren_pos + 1
|
||||
while pos < len(source) and paren_count > 0:
|
||||
if source[pos] == '(':
|
||||
paren_count += 1
|
||||
elif source[pos] == ')':
|
||||
paren_count -= 1
|
||||
pos += 1
|
||||
|
||||
# Now pos is after the closing paren. Find the colon
|
||||
colon_pos = source.find(':', pos)
|
||||
|
||||
# Skip past any docstring if present
|
||||
after_colon = source[colon_pos + 1:colon_pos + 10].lstrip()
|
||||
if after_colon.startswith('"""') or after_colon.startswith("'''"):
|
||||
quote_type = '"""' if after_colon.startswith('"""') else "'''"
|
||||
docstring_start = source.find(quote_type, colon_pos)
|
||||
docstring_end = source.find(quote_type, docstring_start + 3) + 3
|
||||
method_body_start = docstring_end
|
||||
else:
|
||||
method_body_start = colon_pos + 1
|
||||
|
||||
# Find the next method definition to know where this method ends
|
||||
next_method_pattern = r'async def \w+\s*\('
|
||||
next_match = re.search(next_method_pattern, source[method_body_start:])
|
||||
|
||||
if next_match:
|
||||
method_body_end = method_body_start + next_match.start()
|
||||
else:
|
||||
# Last method - use rest of file
|
||||
method_body_end = len(source)
|
||||
|
||||
method_body = source[method_body_start:method_body_end]
|
||||
|
||||
# Extract fields from the method body
|
||||
# Look for: fields = [...] or fields = common_fields + [...]
|
||||
|
||||
# First check if this method uses common_fields
|
||||
uses_common_fields = 'common_fields' in method_body[:500]
|
||||
|
||||
if uses_common_fields:
|
||||
# Pattern: fields = common_fields + [...]
|
||||
fields_pattern = r'fields\s*=\s*common_fields\s*\+\s*\[(.*?)\]'
|
||||
fields_match = re.search(fields_pattern, method_body, re.DOTALL)
|
||||
if fields_match:
|
||||
fields_str = fields_match.group(1)
|
||||
# Extract individual field names
|
||||
field_pattern = r'AdsInsights\.Field\.(\w+)'
|
||||
fields = re.findall(field_pattern, fields_str)
|
||||
|
||||
# Also get common_fields from the module level
|
||||
common_pattern = r'common_fields\s*=\s*\[(.*?)\]'
|
||||
common_match = re.search(common_pattern, source, re.DOTALL)
|
||||
if common_match:
|
||||
common_str = common_match.group(1)
|
||||
common_fields_list = re.findall(field_pattern, common_str)
|
||||
fields = common_fields_list + fields
|
||||
|
||||
result[method_name] = fields
|
||||
else:
|
||||
# Pattern: fields = [...]
|
||||
# Use bracket matching to find the correct field list
|
||||
fields_keyword_pos = method_body.find('fields =')
|
||||
|
||||
if fields_keyword_pos != -1:
|
||||
# Find the opening bracket after fields =
|
||||
bracket_pos = method_body.find('[', fields_keyword_pos)
|
||||
if bracket_pos != -1:
|
||||
# Count brackets to find the matching closing bracket
|
||||
bracket_count = 0
|
||||
end_pos = bracket_pos
|
||||
for i, char in enumerate(method_body[bracket_pos:]):
|
||||
if char == '[':
|
||||
bracket_count += 1
|
||||
elif char == ']':
|
||||
bracket_count -= 1
|
||||
if bracket_count == 0:
|
||||
end_pos = bracket_pos + i
|
||||
break
|
||||
|
||||
fields_str = method_body[bracket_pos + 1:end_pos]
|
||||
field_pattern = r'AdsInsights\.Field\.(\w+)'
|
||||
fields = re.findall(field_pattern, fields_str)
|
||||
result[method_name] = fields
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema_columns():
|
||||
"""Parse and cache the schema columns."""
|
||||
return parse_sql_schema()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def extracted_fields_by_method():
|
||||
"""Extract and cache the fields from each grab_* method."""
|
||||
return extract_fields_from_grabber_source()
|
||||
|
||||
|
||||
# Mapping of method names to their insight table names
|
||||
METHOD_TO_TABLE = {
|
||||
'grab_account_insights': 'account_insights',
|
||||
'grab_campaign_insights': 'campaign_insights',
|
||||
'grab_adset_insights': 'adset_insights',
|
||||
'grab_campaign_insights_by_country': 'campaign_insights_by_country',
|
||||
}
|
||||
|
||||
# Fields that are IDs/names stored in metadata tables, not in the insights table
|
||||
METADATA_ONLY_FIELDS = {
|
||||
'campaign_id', 'campaign_name',
|
||||
'adset_id', 'adset_name',
|
||||
}
|
||||
|
||||
|
||||
class TestFieldSchemaValidation:
|
||||
"""Validate that all API field requests have corresponding database columns."""
|
||||
|
||||
def test_grab_account_insights_fields(self, schema_columns, extracted_fields_by_method):
|
||||
"""Test that grab_account_insights fields exist in schema."""
|
||||
method_name = 'grab_account_insights'
|
||||
table_name = METHOD_TO_TABLE[method_name]
|
||||
|
||||
assert method_name in extracted_fields_by_method, f"Could not extract fields from {method_name}"
|
||||
|
||||
extracted_fields = set(extracted_fields_by_method[method_name])
|
||||
table_cols = schema_columns.get(table_name, set())
|
||||
assert table_cols, f"Table {table_name} not found in schema"
|
||||
|
||||
missing = extracted_fields - table_cols
|
||||
assert not missing, \
|
||||
f"{table_name} table missing columns: {missing}\n" \
|
||||
f"Method requests: {sorted(extracted_fields)}\n" \
|
||||
f"Available: {sorted(table_cols)}"
|
||||
|
||||
print(f"✓ {method_name} → {table_name}: {len(extracted_fields)} fields validated")
|
||||
|
||||
def test_grab_campaign_insights_fields(self, schema_columns, extracted_fields_by_method):
|
||||
"""Test that grab_campaign_insights fields exist in schema."""
|
||||
method_name = 'grab_campaign_insights'
|
||||
table_name = METHOD_TO_TABLE[method_name]
|
||||
|
||||
assert method_name in extracted_fields_by_method, f"Could not extract fields from {method_name}"
|
||||
|
||||
extracted_fields = set(extracted_fields_by_method[method_name])
|
||||
table_cols = schema_columns.get(table_name, set())
|
||||
assert table_cols, f"Table {table_name} not found in schema"
|
||||
|
||||
# Remove ID/name fields (stored in metadata tables, not insights table)
|
||||
insight_only_fields = extracted_fields - METADATA_ONLY_FIELDS
|
||||
|
||||
missing = insight_only_fields - table_cols
|
||||
assert not missing, \
|
||||
f"{table_name} table missing columns: {missing}\n" \
|
||||
f"Method requests: {sorted(extracted_fields)}\n" \
|
||||
f"Available: {sorted(table_cols)}"
|
||||
|
||||
print(f"✓ {method_name} → {table_name}: {len(extracted_fields)} fields validated")
|
||||
|
||||
def test_grab_adset_insights_fields(self, schema_columns, extracted_fields_by_method):
|
||||
"""Test that grab_adset_insights fields exist in schema."""
|
||||
method_name = 'grab_adset_insights'
|
||||
table_name = METHOD_TO_TABLE[method_name]
|
||||
|
||||
assert method_name in extracted_fields_by_method, f"Could not extract fields from {method_name}"
|
||||
|
||||
extracted_fields = set(extracted_fields_by_method[method_name])
|
||||
table_cols = schema_columns.get(table_name, set())
|
||||
assert table_cols, f"Table {table_name} not found in schema"
|
||||
|
||||
# Remove ID/name fields (stored in metadata tables, not insights table)
|
||||
insight_only_fields = extracted_fields - METADATA_ONLY_FIELDS
|
||||
|
||||
missing = insight_only_fields - table_cols
|
||||
assert not missing, \
|
||||
f"{table_name} table missing columns: {missing}\n" \
|
||||
f"Method requests: {sorted(extracted_fields)}\n" \
|
||||
f"Available: {sorted(table_cols)}"
|
||||
|
||||
print(f"✓ {method_name} → {table_name}: {len(extracted_fields)} fields validated")
|
||||
|
||||
def test_grab_campaign_insights_by_country_fields(self, schema_columns, extracted_fields_by_method):
|
||||
"""Test that grab_campaign_insights_by_country fields exist in schema."""
|
||||
method_name = 'grab_campaign_insights_by_country'
|
||||
table_name = METHOD_TO_TABLE[method_name]
|
||||
|
||||
assert method_name in extracted_fields_by_method, f"Could not extract fields from {method_name}"
|
||||
|
||||
extracted_fields = set(extracted_fields_by_method[method_name])
|
||||
table_cols = schema_columns.get(table_name, set())
|
||||
assert table_cols, f"Table {table_name} not found in schema"
|
||||
|
||||
# Remove ID/name fields (stored in metadata tables, not insights table)
|
||||
insight_only_fields = extracted_fields - METADATA_ONLY_FIELDS
|
||||
|
||||
# Country is special - it's part of the breakdown
|
||||
assert "country" in table_cols, \
|
||||
f"country field missing in {table_name} table\n" \
|
||||
f"Available: {sorted(table_cols)}"
|
||||
|
||||
missing = insight_only_fields - table_cols
|
||||
assert not missing, \
|
||||
f"{table_name} table missing columns: {missing}\n" \
|
||||
f"Method requests: {sorted(extracted_fields)}\n" \
|
||||
f"Available: {sorted(table_cols)}"
|
||||
|
||||
print(f"✓ {method_name} → {table_name}: {len(extracted_fields)} fields validated")
|
||||
|
||||
def test_all_tables_exist(self, schema_columns):
|
||||
"""Test that all required insight tables exist in schema."""
|
||||
required_tables = {
|
||||
"account_insights",
|
||||
"campaign_insights",
|
||||
"adset_insights",
|
||||
"campaign_insights_by_country",
|
||||
}
|
||||
|
||||
existing_tables = set(schema_columns.keys())
|
||||
missing = required_tables - existing_tables
|
||||
|
||||
assert not missing, \
|
||||
f"Missing tables: {missing}\n" \
|
||||
f"Found: {sorted(existing_tables)}"
|
||||
|
||||
def test_schema_documentation(self, schema_columns):
|
||||
"""Print out the parsed schema for verification."""
|
||||
print("\n" + "="*80)
|
||||
print("PARSED DATABASE SCHEMA")
|
||||
print("="*80)
|
||||
|
||||
for table_name in sorted(schema_columns.keys()):
|
||||
columns = sorted(schema_columns[table_name])
|
||||
print(f"\nTable: {table_name}")
|
||||
print(f"Columns ({len(columns)}): {', '.join(columns)}")
|
||||
|
||||
def test_extracted_fields_documentation(self, extracted_fields_by_method):
|
||||
"""Print out extracted fields from each method."""
|
||||
print("\n" + "="*80)
|
||||
print("EXTRACTED FIELDS FROM GRAB METHODS")
|
||||
print("="*80)
|
||||
|
||||
for method_name, fields in sorted(extracted_fields_by_method.items()):
|
||||
print(f"\n{method_name}:")
|
||||
print(f" Fields ({len(fields)}): {', '.join(sorted(set(fields)))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
893
uv.lock
generated
893
uv.lock
generated
@@ -6,109 +6,6 @@ resolution-markers = [
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
{ name = "aiosignal" },
|
||||
{ name = "attrs" },
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/fa/3ae643cd525cf6844d3dc810481e5748107368eb49563c15a5fb9f680750/aiohttp-3.13.1.tar.gz", hash = "sha256:4b7ee9c355015813a6aa085170b96ec22315dabc3d866fd77d147927000e9464", size = 7835344, upload-time = "2025-10-17T14:03:29.337Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6d/d267b132342e1080f4c1bb7e1b4e96b168b3cbce931ec45780bff693ff95/aiohttp-3.13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55785a7f8f13df0c9ca30b5243d9909bd59f48b274262a8fe78cee0828306e5d", size = 730727, upload-time = "2025-10-17T14:00:39.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c8/1cf495bac85cf71b80fad5f6d7693e84894f11b9fe876b64b0a1e7cbf32f/aiohttp-3.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bef5b83296cebb8167707b4f8d06c1805db0af632f7a72d7c5288a84667e7c3", size = 488678, upload-time = "2025-10-17T14:00:41.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/19/23c6b81cca587ec96943d977a58d11d05a82837022e65cd5502d665a7d11/aiohttp-3.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27af0619c33f9ca52f06069ec05de1a357033449ab101836f431768ecfa63ff5", size = 487637, upload-time = "2025-10-17T14:00:43.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/58/8f9464afb88b3eed145ad7c665293739b3a6f91589694a2bb7e5778cbc72/aiohttp-3.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a47fe43229a8efd3764ef7728a5c1158f31cdf2a12151fe99fde81c9ac87019c", size = 1718975, upload-time = "2025-10-17T14:00:45.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8b/c3da064ca392b2702f53949fd7c403afa38d9ee10bf52c6ad59a42537103/aiohttp-3.13.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e68e126de5b46e8b2bee73cab086b5d791e7dc192056916077aa1e2e2b04437", size = 1686905, upload-time = "2025-10-17T14:00:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/a4/9c8a3843ecf526daee6010af1a66eb62579be1531d2d5af48ea6f405ad3c/aiohttp-3.13.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e65ef49dd22514329c55970d39079618a8abf856bae7147913bb774a3ab3c02f", size = 1754907, upload-time = "2025-10-17T14:00:49.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/80/1f470ed93e06436e3fc2659a9fc329c192fa893fb7ed4e884d399dbfb2a8/aiohttp-3.13.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e425a7e0511648b3376839dcc9190098671a47f21a36e815b97762eb7d556b0", size = 1857129, upload-time = "2025-10-17T14:00:51.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/e6/33d305e6cce0a8daeb79c7d8d6547d6e5f27f4e35fa4883fc9c9eb638596/aiohttp-3.13.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:010dc9b7110f055006acd3648d5d5955bb6473b37c3663ec42a1b4cba7413e6b", size = 1738189, upload-time = "2025-10-17T14:00:53.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/42/8df03367e5a64327fe0c39291080697795430c438fc1139c7cc1831aa1df/aiohttp-3.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b5c722d0ca5f57d61066b5dfa96cdb87111e2519156b35c1f8dd17c703bee7a", size = 1553608, upload-time = "2025-10-17T14:00:56.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/17/6d5c73cd862f1cf29fddcbb54aac147037ff70a043a2829d03a379e95742/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:93029f0e9b77b714904a281b5aa578cdc8aa8ba018d78c04e51e1c3d8471b8ec", size = 1681809, upload-time = "2025-10-17T14:00:58.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/31/8926c8ab18533f6076ce28d2c329a203b58c6861681906e2d73b9c397588/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d1824c7d08d8ddfc8cb10c847f696942e5aadbd16fd974dfde8bd2c3c08a9fa1", size = 1711161, upload-time = "2025-10-17T14:01:01.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/36/2f83e1ca730b1e0a8cf1c8ab9559834c5eec9f5da86e77ac71f0d16b521d/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8f47d0ff5b3eb9c1278a2f56ea48fda667da8ebf28bd2cb378b7c453936ce003", size = 1731999, upload-time = "2025-10-17T14:01:04.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ec/1f818cc368dfd4d5ab4e9efc8f2f6f283bfc31e1c06d3e848bcc862d4591/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a396b1da9b51ded79806ac3b57a598f84e0769eaa1ba300655d8b5e17b70c7b", size = 1548684, upload-time = "2025-10-17T14:01:06.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ad/33d36efd16e4fefee91b09a22a3a0e1b830f65471c3567ac5a8041fac812/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d9c52a65f54796e066b5d674e33b53178014752d28bca555c479c2c25ffcec5b", size = 1756676, upload-time = "2025-10-17T14:01:09.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/c4/4a526d84e77d464437713ca909364988ed2e0cd0cdad2c06cb065ece9e08/aiohttp-3.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a89da72d18d6c95a653470b78d8ee5aa3c4b37212004c103403d0776cbea6ff0", size = 1715577, upload-time = "2025-10-17T14:01:11.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/21/e39638b7d9c7f1362c4113a91870f89287e60a7ea2d037e258b81e8b37d5/aiohttp-3.13.1-cp313-cp313-win32.whl", hash = "sha256:02e0258b7585ddf5d01c79c716ddd674386bfbf3041fbbfe7bdf9c7c32eb4a9b", size = 424468, upload-time = "2025-10-17T14:01:14.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/00/f3a92c592a845ebb2f47d102a67f35f0925cb854c5e7386f1a3a1fdff2ab/aiohttp-3.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:ef56ffe60e8d97baac123272bde1ab889ee07d3419606fae823c80c2b86c403e", size = 450806, upload-time = "2025-10-17T14:01:16.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/be/0f6c41d2fd0aab0af133c509cabaf5b1d78eab882cb0ceb872e87ceeabf7/aiohttp-3.13.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:77f83b3dc5870a2ea79a0fcfdcc3fc398187ec1675ff61ec2ceccad27ecbd303", size = 733828, upload-time = "2025-10-17T14:01:18.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/14/24e2ac5efa76ae30e05813e0f50737005fd52da8ddffee474d4a5e7f38a6/aiohttp-3.13.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9cafd2609ebb755e47323306c7666283fbba6cf82b5f19982ea627db907df23a", size = 489320, upload-time = "2025-10-17T14:01:20.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5a/4cbe599358d05ea7db4869aff44707b57d13f01724d48123dc68b3288d5a/aiohttp-3.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9c489309a2ca548d5f11131cfb4092f61d67954f930bba7e413bcdbbb82d7fae", size = 489899, upload-time = "2025-10-17T14:01:22.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/96/3aec9d9cfc723273d4386328a1e2562cf23629d2f57d137047c49adb2afb/aiohttp-3.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79ac15fe5fdbf3c186aa74b656cd436d9a1e492ba036db8901c75717055a5b1c", size = 1716556, upload-time = "2025-10-17T14:01:25.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/99/39a3d250595b5c8172843831221fa5662884f63f8005b00b4034f2a7a836/aiohttp-3.13.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:095414be94fce3bc080684b4cd50fb70d439bc4662b2a1984f45f3bf9ede08aa", size = 1665814, upload-time = "2025-10-17T14:01:27.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/96/8319e7060a85db14a9c178bc7b3cf17fad458db32ba6d2910de3ca71452d/aiohttp-3.13.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c68172e1a2dca65fa1272c85ca72e802d78b67812b22827df01017a15c5089fa", size = 1755767, upload-time = "2025-10-17T14:01:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c6/0a2b3d886b40aa740fa2294cd34ed46d2e8108696748492be722e23082a7/aiohttp-3.13.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3751f9212bcd119944d4ea9de6a3f0fee288c177b8ca55442a2cdff0c8201eb3", size = 1836591, upload-time = "2025-10-17T14:01:32.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/34/8ab5904b3331c91a58507234a1e2f662f837e193741609ee5832eb436251/aiohttp-3.13.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8619dca57d98a8353abdc7a1eeb415548952b39d6676def70d9ce76d41a046a9", size = 1714915, upload-time = "2025-10-17T14:01:35.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/d3/d36077ca5f447649112189074ac6c192a666bf68165b693e48c23b0d008c/aiohttp-3.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97795a0cb0a5f8a843759620e9cbd8889f8079551f5dcf1ccd99ed2f056d9632", size = 1546579, upload-time = "2025-10-17T14:01:38.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/14/dbc426a1bb1305c4fc78ce69323498c9e7c699983366ef676aa5d3f949fa/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1060e058da8f9f28a7026cdfca9fc886e45e551a658f6a5c631188f72a3736d2", size = 1680633, upload-time = "2025-10-17T14:01:40.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/83/1e68e519aff9f3ef6d4acb6cdda7b5f592ef5c67c8f095dc0d8e06ce1c3e/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f48a2c26333659101ef214907d29a76fe22ad7e912aa1e40aeffdff5e8180977", size = 1678675, upload-time = "2025-10-17T14:01:43.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/b9/7f3e32a81c08b6d29ea15060c377e1f038ad96cd9923a85f30e817afff22/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1dfad638b9c91ff225162b2824db0e99ae2d1abe0dc7272b5919701f0a1e685", size = 1726829, upload-time = "2025-10-17T14:01:46.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ce/610b1f77525a0a46639aea91377b12348e9f9412cc5ddcb17502aa4681c7/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8fa09ab6dd567cb105db4e8ac4d60f377a7a94f67cf669cac79982f626360f32", size = 1542985, upload-time = "2025-10-17T14:01:49.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/3ac8dfdad5de38c401846fa071fcd24cb3b88ccfb024854df6cbd9b4a07e/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4159fae827f9b5f655538a4f99b7cbc3a2187e5ca2eee82f876ef1da802ccfa9", size = 1741556, upload-time = "2025-10-17T14:01:51.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/48/b1948b74fea7930b0f29595d1956842324336de200593d49a51a40607fdc/aiohttp-3.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ad671118c19e9cfafe81a7a05c294449fe0ebb0d0c6d5bb445cd2190023f5cef", size = 1696175, upload-time = "2025-10-17T14:01:54.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/26/063bba38e4b27b640f56cc89fe83cc3546a7ae162c2e30ca345f0ccdc3d1/aiohttp-3.13.1-cp314-cp314-win32.whl", hash = "sha256:c5c970c148c48cf6acb65224ca3c87a47f74436362dde75c27bc44155ccf7dfc", size = 430254, upload-time = "2025-10-17T14:01:56.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/aa/25fd764384dc4eab714023112d3548a8dd69a058840d61d816ea736097a2/aiohttp-3.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:748a00167b7a88385756fa615417d24081cba7e58c8727d2e28817068b97c18c", size = 456256, upload-time = "2025-10-17T14:01:58.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/9f/9ba6059de4bad25c71cd88e3da53f93e9618ea369cf875c9f924b1c167e2/aiohttp-3.13.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:390b73e99d7a1f0f658b3f626ba345b76382f3edc65f49d6385e326e777ed00e", size = 765956, upload-time = "2025-10-17T14:02:01.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/30/b86da68b494447d3060f45c7ebb461347535dab4af9162a9267d9d86ca31/aiohttp-3.13.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e83abb330e687e019173d8fc1fd6a1cf471769624cf89b1bb49131198a810a", size = 503206, upload-time = "2025-10-17T14:02:03.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/21/d27a506552843ff9eeb9fcc2d45f943b09eefdfdf205aab044f4f1f39f6a/aiohttp-3.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b20eed07131adbf3e873e009c2869b16a579b236e9d4b2f211bf174d8bef44a", size = 507719, upload-time = "2025-10-17T14:02:05.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/23/4042230ec7e4edc7ba43d0342b5a3d2fe0222ca046933c4251a35aaf17f5/aiohttp-3.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58fee9ef8477fd69e823b92cfd1f590ee388521b5ff8f97f3497e62ee0656212", size = 1862758, upload-time = "2025-10-17T14:02:08.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/88/525c45bea7cbb9f65df42cadb4ff69f6a0dbf95931b0ff7d1fdc40a1cb5f/aiohttp-3.13.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1f62608fcb7b3d034d5e9496bea52d94064b7b62b06edba82cd38191336bbeda", size = 1717790, upload-time = "2025-10-17T14:02:11.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/80/21e9b5eb77df352a5788713f37359b570a793f0473f3a72db2e46df379b9/aiohttp-3.13.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdc4d81c3dfc999437f23e36d197e8b557a3f779625cd13efe563a9cfc2ce712", size = 1842088, upload-time = "2025-10-17T14:02:13.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bf/d1738f6d63fe8b2a0ad49533911b3347f4953cd001bf3223cb7b61f18dff/aiohttp-3.13.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:601d7ec812f746fd80ff8af38eeb3f196e1bab4a4d39816ccbc94c222d23f1d0", size = 1934292, upload-time = "2025-10-17T14:02:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e6/26cab509b42610ca49573f2fc2867810f72bd6a2070182256c31b14f2e98/aiohttp-3.13.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47c3f21c469b840d9609089435c0d9918ae89f41289bf7cc4afe5ff7af5458db", size = 1791328, upload-time = "2025-10-17T14:02:19.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6d/baf7b462852475c9d045bee8418d9cdf280efb687752b553e82d0c58bcc2/aiohttp-3.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6c6cdc0750db88520332d4aaa352221732b0cafe89fd0e42feec7cb1b5dc236", size = 1622663, upload-time = "2025-10-17T14:02:21.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/48/396a97318af9b5f4ca8b3dc14a67976f71c6400a9609c622f96da341453f/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:58a12299eeb1fca2414ee2bc345ac69b0f765c20b82c3ab2a75d91310d95a9f6", size = 1787791, upload-time = "2025-10-17T14:02:24.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/e2/6925f6784134ce3ff3ce1a8502ab366432a3b5605387618c1a939ce778d9/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0989cbfc195a4de1bb48f08454ef1cb47424b937e53ed069d08404b9d3c7aea1", size = 1775459, upload-time = "2025-10-17T14:02:26.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e3/b372047ba739fc39f199b99290c4cc5578ce5fd125f69168c967dac44021/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:feb5ee664300e2435e0d1bc3443a98925013dfaf2cae9699c1f3606b88544898", size = 1789250, upload-time = "2025-10-17T14:02:29.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8c/9f48b93d7d57fc9ef2ad4adace62e4663ea1ce1753806c4872fb36b54c39/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:58a6f8702da0c3606fb5cf2e669cce0ca681d072fe830968673bb4c69eb89e88", size = 1616139, upload-time = "2025-10-17T14:02:32.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/c6/c64e39d61aaa33d7de1be5206c0af3ead4b369bf975dac9fdf907a4291c1/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a417ceb433b9d280e2368ffea22d4bc6e3e0d894c4bc7768915124d57d0964b6", size = 1815829, upload-time = "2025-10-17T14:02:34.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/75/e19e93965ea675f1151753b409af97a14f1d888588a555e53af1e62b83eb/aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ac8854f7b0466c5d6a9ea49249b3f6176013859ac8f4bb2522ad8ed6b94ded2", size = 1760923, upload-time = "2025-10-17T14:02:37.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a4/06ed38f1dabd98ea136fd116cba1d02c9b51af5a37d513b6850a9a567d86/aiohttp-3.13.1-cp314-cp314t-win32.whl", hash = "sha256:be697a5aeff42179ed13b332a411e674994bcd406c81642d014ace90bf4bb968", size = 463318, upload-time = "2025-10-17T14:02:39.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/0f/27e4fdde899e1e90e35eeff56b54ed63826435ad6cdb06b09ed312d1b3fa/aiohttp-3.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1d6aa90546a4e8f20c3500cb68ab14679cd91f927fa52970035fd3207dfb3da", size = 496721, upload-time = "2025-10-17T14:02:42.199Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.30.0"
|
||||
@@ -126,388 +23,21 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
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/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.1"
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curlify"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/df/e9c2720d32c5de985ce64d5dedf883b52c26ff5e9aa2ba2becea00098320/curlify-3.0.0.tar.gz", hash = "sha256:7b488ff3c924dba3433a1cc74044c0942da21f0a97fa26c3138319ba640ca412", size = 3668, upload-time = "2025-05-25T11:50:00.785Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f8/912ebddbff8ea603d4c90fa31557096f927b17efd30a166ce7ac1242910a/curlify-3.0.0-py3-none-any.whl", hash = "sha256:52060c0eb7a656b7bde6b668c32f337bed4d736ce230755767e3ada56a09c338", size = 3580, upload-time = "2025-05-25T11:49:59.335Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "facebook-business"
|
||||
version = "23.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "curlify" },
|
||||
{ name = "pycountry" },
|
||||
{ name = "requests" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/56/93d9d801a6cfea902d7383d45038499508aa248afea64d7ec1939f931486/facebook_business-23.0.3.tar.gz", hash = "sha256:5e58a0b953c32fd53337c2acef11b8b889bf3a12481353deaaa30cfb67fd25b0", size = 672233, upload-time = "2025-10-14T16:16:57.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/a8/8c594fcee90b2776a28256e1a3547872b47f7813b100c064a84270be24c9/facebook_business-23.0.3-py3-none-any.whl", hash = "sha256:2beafd128d97f48d074400aa18112fff758ac1e34f8d87cb4717f200f8e7e32c", size = 1391948, upload-time = "2025-10-14T16:16:56.015Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-ads"
|
||||
version = "28.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-status" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/ee/30bf06a8334333a43805050c7530637b7c308f371945e3cad7d78b4c5287/google_ads-28.3.0.tar.gz", hash = "sha256:d544e7e3792974e9dc6a016e0eb264f9218526be698c8c6b8a438717a6dcc95b", size = 9222858, upload-time = "2025-10-22T16:22:43.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/cf/21541e673e47582ac46b164817ff359370ed9897db977225587f5290b202/google_ads-28.3.0-py3-none-any.whl", hash = "sha256:11ec6227784a565de3ad3f0047ac82eb13c6bfca1d4a5862df9b3c63162fbb40", size = 17781520, upload-time = "2025-10-22T16:22:40.881Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/da/83d7043169ac2c8c7469f0e375610d78ae2160134bf1b80634c482fa079c/google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", size = 176759, upload-time = "2025-10-28T21:34:51.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.42.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/75/28881e9d7de9b3d61939bc9624bd8fa594eb787a00567aba87173c790f09/google_auth-2.42.0.tar.gz", hash = "sha256:9bbbeef3442586effb124d1ca032cfb8fb7acd8754ab79b55facd2b8f3ab2802", size = 295400, upload-time = "2025-10-28T17:38:08.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/24/ec82aee6ba1a076288818fe5cc5125f4d93fffdc68bb7b381c68286c8aaa/google_auth-2.42.0-py2.py3-none-any.whl", hash = "sha256:f8f944bcb9723339b0ef58a73840f3c61bc91b69bf7368464906120b55804473", size = 222550, upload-time = "2025-10-28T17:38:05.496Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-oauthlib"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955, upload-time = "2025-04-22T16:40:29.172Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.71.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/43/b25abe02db2911397819003029bef768f68a974f2ece483e6084d1a5f754/googleapis_common_protos-1.71.0.tar.gz", hash = "sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e", size = 146454, upload-time = "2025-10-20T14:58:08.732Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e8/eba9fece11d57a71e3e22ea672742c8f3cf23b35730c9e96db768b295216/googleapis_common_protos-1.71.0-py3-none-any.whl", hash = "sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c", size = 294576, upload-time = "2025-10-20T14:56:21.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.76.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.76.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -515,242 +45,80 @@ name = "meta-api-grabber"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "facebook-business" },
|
||||
{ name = "google-ads" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.1" },
|
||||
{ name = "alembic", specifier = ">=1.17.0" },
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "facebook-business", specifier = ">=23.0.3" },
|
||||
{ name = "google-ads", specifier = ">=28.3.0" },
|
||||
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||
{ name = "requests-oauthlib", specifier = ">=2.0.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.44" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
]
|
||||
provides-extras = ["test"]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.7.0"
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.4.1"
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
|
||||
{ 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/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proto-plus"
|
||||
version = "1.26.1"
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
name = "pytest-asyncio"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycountry"
|
||||
version = "24.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -797,174 +165,3 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
asyncio = [
|
||||
{ name = "greenlet" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.22.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user