diff --git a/.gitignore b/.gitignore index 5d02d80a..b8206ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -184,7 +184,7 @@ cython_debug/ # Abstra # Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. +# Ignore directories containing credentials, local state, and settings. # Learn more at https://abstra.io/docs .abstra/ diff --git a/README.md b/README.md index f7e90a6c..99eed13e 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,4 @@ _If you're looking at this in Cursor, please right click on the filename in the - Please submit your community_contributions, including links to your repos, in the production repo community_contributions folder - Regularly do a git pull to get the latest code -- Reach out in Udemy or email (ed@edwarddonner.com) if I can help! This is a gigantic project and I am here to help you deliver it! \ No newline at end of file +- Reach out in Udemy or email (ed@edwarddonner.com) if I can help! This is a gigantic project and I am here to help you deliver it! diff --git a/backend/agent_charter/.bedrock_agentcore.yaml b/backend/agent_charter/.bedrock_agentcore.yaml new file mode 100644 index 00000000..c2a3bb25 --- /dev/null +++ b/backend/agent_charter/.bedrock_agentcore.yaml @@ -0,0 +1,41 @@ +default_agent: charter +agents: + charter: + name: charter + entrypoint: /Users/fotis/Documents/CV/Learning/AI in production/alex/backend/agent_charter/agent.py + platform: linux/arm64 + container_runtime: docker + source_path: null + aws: + execution_role: arn:aws:iam::717174128108:role/agentcore-charter-role + execution_role_auto_create: false + account: '717174128108' + region: us-east-1 + ecr_repository: 717174128108.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-charter + ecr_auto_create: false + network_configuration: + network_mode: PUBLIC + network_mode_config: null + protocol_configuration: + server_protocol: HTTP + observability: + enabled: true + bedrock_agentcore: + agent_id: charter-mxi7b3F18T + agent_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:runtime/charter-mxi7b3F18T + agent_session_id: null + codebuild: + project_name: bedrock-agentcore-charter-builder + execution_role: arn:aws:iam::717174128108:role/AmazonBedrockAgentCoreSDKCodeBuild-us-east-1-8399e57405 + source_bucket: bedrock-agentcore-codebuild-sources-717174128108-us-east-1 + memory: + mode: STM_ONLY + memory_id: charter_mem-LPolp73HEF + memory_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:memory/charter_mem-LPolp73HEF + memory_name: charter_mem + event_expiry_days: 30 + first_invoke_memory_check_done: false + was_created_by_toolkit: false + authorizer_configuration: null + request_header_configuration: null + oauth_configuration: null diff --git a/backend/agent_charter/.dockerignore b/backend/agent_charter/.dockerignore new file mode 100644 index 00000000..bf13996c --- /dev/null +++ b/backend/agent_charter/.dockerignore @@ -0,0 +1,69 @@ +# Build artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Python cache +__pycache__/ +__pycache__* +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +.env +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +.coverage* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.mypy_cache/ +.ruff_cache/ + +# Development +*.log +*.bak +*.swp +*.swo +*~ +.DS_Store + +# IDEs +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Project specific +tests/ + +# Bedrock AgentCore specific - keep config but exclude runtime files +.bedrock_agentcore.yaml +.dockerignore +.bedrock_agentcore/ + +# Keep wheelhouse for offline installations +# wheelhouse/ diff --git a/backend/agent_charter/.gitignore b/backend/agent_charter/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_charter/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_charter/Dockerfile b/backend/agent_charter/Dockerfile new file mode 100644 index 00000000..9db17dd9 --- /dev/null +++ b/backend/agent_charter/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app + +# All environment variables in one layer +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + AWS_REGION=us-east-1 \ + AWS_DEFAULT_REGION=us-east-1 \ + BEDROCK_AGENTCORE_MEMORY_ID=charter_mem-LPolp73HEF \ + BEDROCK_AGENTCORE_MEMORY_NAME=charter_mem + + + +COPY requirements.txt requirements.txt +# Install from requirements file +RUN uv pip install -r requirements.txt + + + + +RUN uv pip install aws-opentelemetry-distro>=0.10.1 + + +# Signal that this is running in Docker for host binding logic +ENV DOCKER_CONTAINER=1 + +# Create non-root user +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 9000 +EXPOSE 8000 +EXPOSE 8080 + +# Copy entire project (respecting .dockerignore) +COPY . . + +# Use the full module path + +CMD ["opentelemetry-instrument", "python", "-m", "agent"] diff --git a/backend/agent_charter/agent.py b/backend/agent_charter/agent.py new file mode 100644 index 00000000..07176c93 --- /dev/null +++ b/backend/agent_charter/agent.py @@ -0,0 +1,473 @@ +""" +Chart Maker Agent using Bedrock AgentCore. + +This agent analyzes portfolio data and generates visualization charts in JSON format. +""" + +import json +import logging +import os +import uuid +from typing import Dict, Any, List + +from utils import load_env_from_ssm + +# Load environment variables from SSM at startup +import sys +sys.path.append('/opt/python') # Add common layer path if available + + +load_env_from_ssm() +print("✅ Loaded environment variables from SSM") + +from strands import Agent, tool +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +logger = logging.getLogger(__name__) + +def analyze_portfolio(portfolio_data: Dict[str, Any]) -> str: + """ + Analyze the portfolio to understand its composition and calculate key metrics. + Returns detailed breakdown of positions, accounts, and calculated allocations. + """ + result = [] + total_value = 0.0 + position_values = {} + account_totals = {} + + # Calculate position values and totals + for account in portfolio_data.get("accounts", []): + account_name = account.get("account_name", account.get("name", "Unknown")) # Support both field names for backward compatibility + account_type = account.get("type", "unknown") + # Handle None or missing cash_balance + cash_balance = account.get("cash_balance") + if cash_balance is None or cash_balance == "": + cash = 0.0 + else: + cash = float(cash_balance) + + if account_name not in account_totals: + account_totals[account_name] = {"value": 0, "type": account_type, "positions": []} + + account_totals[account_name]["value"] += cash + total_value += cash + + for position in account.get("positions", []): + symbol = position.get("symbol") + quantity = float(position.get("quantity", 0)) + instrument = position.get("instrument", {}) + # Handle None or missing current_price + current_price = instrument.get("current_price") + if current_price is None or current_price == "": + price = 1.0 # Default price if not available + logger.warning(f"Charter: No price for {symbol}, using default of 1.0") + else: + price = float(current_price) + value = quantity * price + + position_values[symbol] = position_values.get(symbol, 0) + value + account_totals[account_name]["value"] += value + account_totals[account_name]["positions"].append( + {"symbol": symbol, "value": value, "instrument": instrument} + ) + total_value += value + + # Build analysis summary + result.append("Portfolio Analysis:") + result.append(f"Total Value: ${total_value:,.2f}") + result.append(f"Number of Accounts: {len(account_totals)}") + result.append(f"Number of Positions: {len(position_values)}") + + result.append("\nAccount Breakdown:") + for name, data in account_totals.items(): + pct = (data["value"] / total_value * 100) if total_value > 0 else 0 + result.append(f" {name} ({data['type']}): ${data['value']:,.2f} ({pct:.1f}%)") + + result.append("\nTop Holdings by Value:") + sorted_positions = sorted(position_values.items(), key=lambda x: x[1], reverse=True)[:10] + for symbol, value in sorted_positions: + pct = (value / total_value * 100) if total_value > 0 else 0 + result.append(f" {symbol}: ${value:,.2f} ({pct:.1f}%)") + + # Calculate aggregated allocations for the agent + result.append("\nCalculated Allocations:") + + # Asset class aggregation + asset_classes = {} + regions = {} + sectors = {} + + for account in portfolio_data.get("accounts", []): + for position in account.get("positions", []): + symbol = position.get("symbol") + quantity = float(position.get("quantity", 0)) + instrument = position.get("instrument", {}) + # Handle None or missing current_price + current_price = instrument.get("current_price") + if current_price is None or current_price == "": + price = 1.0 # Default price if not available + else: + price = float(current_price) + value = quantity * price + + # Aggregate asset classes + for asset_class, pct in instrument.get("allocation_asset_class", {}).items(): + asset_value = value * (pct / 100) + asset_classes[asset_class] = asset_classes.get(asset_class, 0) + asset_value + + # Aggregate regions + for region, pct in instrument.get("allocation_regions", {}).items(): + region_value = value * (pct / 100) + regions[region] = regions.get(region, 0) + region_value + + # Aggregate sectors + for sector, pct in instrument.get("allocation_sectors", {}).items(): + sector_value = value * (pct / 100) + sectors[sector] = sectors.get(sector, 0) + sector_value + + # Add cash to asset classes + total_cash = sum( + float(acc.get("cash_balance")) if acc.get("cash_balance") is not None else 0 + for acc in portfolio_data.get("accounts", []) + ) + if total_cash > 0: + asset_classes["cash"] = asset_classes.get("cash", 0) + total_cash + + result.append("\nAsset Classes:") + for asset_class, value in sorted(asset_classes.items(), key=lambda x: x[1], reverse=True): + result.append(f" {asset_class}: ${value:,.2f}") + + result.append("\nGeographic Regions:") + for region, value in sorted(regions.items(), key=lambda x: x[1], reverse=True): + result.append(f" {region}: ${value:,.2f}") + + result.append("\nSectors:") + for sector, value in sorted(sectors.items(), key=lambda x: x[1], reverse=True)[:10]: + result.append(f" {sector}: ${value:,.2f}") + + return "\n".join(result) + +def create_charter_task(portfolio_analysis: str, portfolio_data: dict) -> str: + """Generate the task prompt for the Charter agent.""" + return f"""Analyze this investment portfolio and create 4-6 visualization charts. + +{portfolio_analysis} + +Create charts based on this portfolio data. Calculate aggregated values from the positions shown above. + +OUTPUT ONLY THE JSON OBJECT with 4-6 charts - no other text.""" + +CHARTER_INSTRUCTIONS = """You are a Chart Maker Agent that creates visualization data for investment portfolios. + +Your task is to analyze the portfolio and output a JSON object containing 4-6 charts that tell a compelling story about the portfolio. + +You must output ONLY valid JSON in the exact format shown below. Do not include any text before or after the JSON. + +REQUIRED JSON FORMAT: +{ + "charts": [ + { + "key": "asset_class_distribution", + "title": "Asset Class Distribution", + "type": "pie", + "description": "Shows the distribution of asset classes in the portfolio", + "data": [ + {"name": "Equity", "value": 146365.00, "color": "#3B82F6"}, + {"name": "Fixed Income", "value": 29000.00, "color": "#10B981"}, + {"name": "Real Estate", "value": 14500.00, "color": "#F59E0B"}, + {"name": "Cash", "value": 5000.00, "color": "#EF4444"} + ] + } + ] +} + +IMPORTANT RULES: +1. Output ONLY the JSON object, nothing else +2. Each chart must have: key, title, type, description, and data array +3. Chart types: 'pie', 'bar', 'donut', or 'horizontalBar' +4. Values must be dollar amounts (not percentages - Recharts calculates those) +5. Colors must be hex format like '#3B82F6' +6. Create 4-6 different charts from different perspectives + +CHART IDEAS TO IMPLEMENT: +- Asset class distribution (equity vs bonds vs alternatives) +- Geographic exposure (North America, Europe, Asia, etc.) +- Sector breakdown (Technology, Healthcare, Financials, etc.) +- Account type allocation (401k, IRA, Taxable, etc.) +- Top holdings concentration (largest 5-10 positions) +- Tax efficiency (tax-advantaged vs taxable accounts) + +Remember: Output ONLY the JSON object. No explanations, no text before or after.""" + +def create_agent_and_run(job_id: str, portfolio_data: Dict[str, Any], user_id: str = None) -> str: + """ + Create and run the charter agent to generate visualization charts. + + Args: + job_id: Job identifier + portfolio_data: Portfolio information + user_id: User identifier (optional) + + Returns: + JSON string with chart data + """ + logger.info(f"Charter Agent: Starting analysis for job {job_id}") + + # Initialize model + model_id = os.getenv("BEDROCK_MODEL_ID", "us.amazon.nova-pro-v1:0") + region = os.getenv("BEDROCK_REGION", "us-west-2") + + logger.info(f"Charter Agent: Using model {model_id} in region {region}") + + model = BedrockModel( + model_id=model_id + ) + + # Analyze portfolio + portfolio_analysis = analyze_portfolio(portfolio_data) + logger.info(f"Charter Agent: Portfolio analysis completed") + + # Create agent + agent = Agent( + name="Chart Maker", + system_prompt=CHARTER_INSTRUCTIONS, + model=model + ) + + # Create task + task = create_charter_task(portfolio_analysis, portfolio_data) + logger.info(f"Charter Agent: Task created, length: {len(task)} characters") + + # Run agent + try: + response = agent(task) + + # Extract text from AgentResult if needed + if hasattr(response, 'text'): + response_text = response.text + else: + response_text = str(response) + + logger.info(f"Charter Agent: Generated response, length: {len(response_text) if response_text else 0}") + + if response_text: + # Extract JSON from response + start_idx = response_text.find('{') + end_idx = response_text.rfind('}') + + if start_idx >= 0 and end_idx > start_idx: + json_str = response_text[start_idx:end_idx + 1] + + # Validate JSON + try: + parsed_data = json.loads(json_str) + charts = parsed_data.get('charts', []) + logger.info(f"Charter Agent: Successfully parsed JSON with {len(charts)} charts") + return json_str + except json.JSONDecodeError as e: + logger.error(f"Charter Agent: Failed to parse JSON: {e}") + return json.dumps({"error": "Failed to parse chart data"}) + else: + logger.error("Charter Agent: No JSON structure found in response") + return json.dumps({"error": "No chart data generated"}) + else: + logger.error("Charter Agent: Empty response") + return json.dumps({"error": "Empty response from agent"}) + + except Exception as e: + logger.error(f"Charter Agent: Error during execution: {e}") + return json.dumps({"error": f"Agent execution failed: {str(e)}"}) + +app = BedrockAgentCoreApp() + + +@app.entrypoint +async def chart_maker_agent(event): + """Chart Maker Agent handler.""" + logger.info(f"Charter Agent: Received event with keys: {list(event.keys()) if isinstance(event, dict) else 'not a dict'}") + + # Extract required data + job_id = event.get('job_id') + portfolio_data = event.get('portfolio_data') + user_id = event.get('user_id') + + if not job_id: + return json.dumps({"error": "job_id is required"}) + + # If portfolio_data is not provided, load it from database + if not portfolio_data: + try: + logger.info(f"Charter Agent: Loading portfolio data from database for job_id: {job_id}") + + # Import database + from src import Database + db = Database() + logger.debug("Charter Agent: Database connection established") + + # Get job info + job = db.jobs.find_by_id(job_id) + if not job: + logger.error(f"Charter Agent: Job {job_id} not found in database") + return json.dumps({"error": f"Job {job_id} not found"}) + + user_id = job["clerk_user_id"] + logger.info(f"Charter Agent: Found job for user_id: {user_id}") + + # Load portfolio data from database + accounts = db.accounts.find_by_user(user_id) + logger.info(f"Charter Agent: Found {len(accounts)} accounts for user {user_id}") + portfolio_data = {"accounts": []} + + total_positions = 0 + for account in accounts: + # Handle None cash_balance safely + cash_balance = account.get("cash_balance") + if cash_balance is None: + cash_balance = 0.0 + else: + cash_balance = float(cash_balance) + + account_data = { + "id": account["id"], + "name": account.get("account_name", f"Account {account['id']}"), + "type": account.get("account_name", "unknown"), # Use account_name as type since no type field exists + "cash_balance": cash_balance, + "positions": [] + } + logger.debug(f"Charter Agent: Processing account '{account.get('account_name', f'Account {account['id']}')}' (ID: {account['id']})") + + # Get positions for this account + positions = db.positions.find_by_account(account["id"]) + logger.debug(f"Charter Agent: Found {len(positions)} positions for account {account.get('account_name', f'Account {account['id']}')}'") + + for position in positions: + # Get instrument data + instrument = db.instruments.find_by_symbol(position["symbol"]) + if instrument: + # Handle None values safely + quantity = position.get("quantity") + if quantity is None: + quantity = 0.0 + else: + quantity = float(quantity) + + current_price = instrument.get("current_price") + if current_price is None: + current_price = 0.0 + else: + current_price = float(current_price) + + position_data = { + "symbol": position["symbol"], + "quantity": quantity, + "instrument": { + "symbol": instrument["symbol"], + "name": instrument.get("name", position["symbol"]), + "current_price": current_price, + "allocation_asset_class": instrument.get("allocation_asset_class", {}), + "allocation_regions": instrument.get("allocation_regions", {}), + "allocation_sectors": instrument.get("allocation_sectors", {}) + } + } + account_data["positions"].append(position_data) + total_positions += 1 + logger.debug(f"Charter Agent: Added position {position['symbol']} (qty: {position['quantity']}) to account {account.get('account_name', f'Account {account['id']}')}'") + else: + logger.warning(f"Charter Agent: Instrument not found for symbol {position['symbol']}") + + portfolio_data["accounts"].append(account_data) + + logger.info(f"Charter Agent: Loaded portfolio data with {len(portfolio_data['accounts'])} accounts and {total_positions} total positions") + + except Exception as e: + logger.error(f"Charter Agent: Error loading portfolio data: {e}") + return json.dumps({"error": f"Error loading portfolio data: {str(e)}"}) + + # Run the agent + result = create_agent_and_run(job_id, portfolio_data, user_id) + + # Save chart data to database + try: + logger.info(f"Charter Agent: Starting database save process for job_id: {job_id}") + if result and not result.startswith('{"error"'): + logger.debug(f"Charter Agent: Parsing chart result (length: {len(result)} chars)") + # Parse the JSON result + chart_json = json.loads(result) + charts = chart_json.get('charts', []) + logger.info(f"Charter Agent: Found {len(charts)} charts in result") + + if charts: + # Import database + from src import Database + db = Database() + logger.debug("Charter Agent: Database connection established for saving") + + # Convert charts array to dictionary with chart keys as top-level keys + charts_data = {} + for i, chart in enumerate(charts): + chart_key = chart.get('key', f"chart_{len(charts_data) + 1}") + # Remove the 'key' from the chart data since it's now the dict key + chart_copy = {k: v for k, v in chart.items() if k != 'key'} + charts_data[chart_key] = chart_copy + logger.debug(f"Charter Agent: Processed chart {i+1}/{len(charts)}: '{chart_key}' (type: {chart_copy.get('type', 'unknown')})") + + logger.info(f"Charter Agent: Attempting to save {len(charts_data)} charts to database for job_id: {job_id}") + logger.debug(f"Charter Agent: Chart data structure prepared with keys: {list(charts_data.keys())}") + logger.debug(f"Charter Agent: Job ID type: {type(job_id)}, value: {job_id}") + + # Save to database with detailed error handling + try: + success = db.jobs.update_charts(job_id, charts_data) + logger.debug(f"Charter Agent: update_charts returned: {success} (type: {type(success)})") + except Exception as db_error: + logger.error(f"Charter Agent: Database update_charts failed with error: {db_error}") + logger.error(f"Charter Agent: Error type: {type(db_error).__name__}") + success = False + + if success: + logger.info(f"Charter Agent: ✅ Successfully saved {len(charts_data)} charts to database") + logger.info(f"Charter Agent: Chart keys saved: {list(charts_data.keys())}") + # Log details about each chart saved + for key, chart in charts_data.items(): + chart_type = chart.get('type', 'unknown') + data_points = len(chart.get('data', [])) if isinstance(chart.get('data'), list) else 0 + logger.debug(f"Charter Agent: Saved chart '{key}': type={chart_type}, data_points={data_points}") + else: + logger.error("Charter Agent: ❌ Database update returned false - save operation failed") + logger.error(f"Charter Agent: Failed to save charts for job_id: {job_id}") + + logger.info(f"Charter Agent: Database save operation completed. Success: {success}") + + if success: + return json.dumps({ + "success": True, + "message": f"Generated and saved {len(charts_data)} charts", + "charts_generated": len(charts_data), + "chart_keys": list(charts_data.keys()) + }) + else: + logger.error("Charter Agent: Failed to save charts to database") + return json.dumps({"error": "Failed to save charts to database"}) + else: + logger.warning("Charter Agent: No charts found in result - charts array was empty") + logger.debug(f"Charter Agent: Full result structure: {json.dumps(chart_json, indent=2)[:500]}...") + return json.dumps({"error": "No charts generated"}) + else: + logger.error(f"Charter Agent: Invalid result format or error result detected") + logger.error(f"Charter Agent: Result preview: {result[:200] if result else 'None'}") + logger.debug(f"Charter Agent: Full result: {result}") + return result # Return original error + + except json.JSONDecodeError as e: + logger.error(f"Charter Agent: JSON parsing error when saving to database: {e}") + logger.error(f"Charter Agent: Invalid JSON result: {result[:500] if result else 'None'}") + return json.dumps({"error": f"Invalid JSON format in chart result: {str(e)}"}) + except Exception as e: + logger.error(f"Charter Agent: Unexpected error during database save operation: {e}") + logger.error(f"Charter Agent: Error type: {type(e).__name__}") + logger.debug(f"Charter Agent: Result that caused error: {result[:500] if result else 'None'}") + return json.dumps({"error": f"Error saving charts: {str(e)}"}) + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/backend/agent_charter/requirements.txt b/backend/agent_charter/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_charter/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_charter/src/__init__.py b/backend/agent_charter/src/__init__.py new file mode 100644 index 00000000..5bc75e95 --- /dev/null +++ b/backend/agent_charter/src/__init__.py @@ -0,0 +1,51 @@ +""" +Database package for Alex Financial Planner +Provides database models, schemas, and Data API client +""" + +from .client import DataAPIClient +from .models import Database +from .schemas import ( + # Types + RegionType, + AssetClassType, + SectorType, + InstrumentType, + JobType, + JobStatus, + AccountType, + + # Create schemas (for inputs) + InstrumentCreate, + UserCreate, + AccountCreate, + PositionCreate, + JobCreate, + JobUpdate, + + # Response schemas (for outputs) + InstrumentResponse, + PortfolioAnalysis, + RebalanceRecommendation, +) + +__all__ = [ + 'Database', + 'DataAPIClient', + 'InstrumentCreate', + 'UserCreate', + 'AccountCreate', + 'PositionCreate', + 'JobCreate', + 'JobUpdate', + 'InstrumentResponse', + 'PortfolioAnalysis', + 'RebalanceRecommendation', + 'RegionType', + 'AssetClassType', + 'SectorType', + 'InstrumentType', + 'JobType', + 'JobStatus', + 'AccountType', +] \ No newline at end of file diff --git a/backend/agent_charter/src/client.py b/backend/agent_charter/src/client.py new file mode 100644 index 00000000..f91994e9 --- /dev/null +++ b/backend/agent_charter/src/client.py @@ -0,0 +1,310 @@ +""" +Aurora Data API Client Wrapper +Provides a simple interface for database operations +""" + +import boto3 +import json +import os +from typing import List, Dict, Any, Optional, Tuple +from datetime import date, datetime +from decimal import Decimal +from botocore.exceptions import ClientError +import logging + +# Try to load .env file if it exists +try: + from dotenv import load_dotenv + + load_dotenv(override=True) +except ImportError: + pass # dotenv not installed, continue without it + +logger = logging.getLogger(__name__) + + +class DataAPIClient: + """Wrapper for AWS RDS Data API to simplify database operations""" + + def __init__( + self, + cluster_arn: str = None, + secret_arn: str = None, + database: str = None, + region: str = None, + ): + """ + Initialize Data API client + + Args: + cluster_arn: Aurora cluster ARN (or from env AURORA_CLUSTER_ARN) + secret_arn: Secrets Manager ARN (or from env AURORA_SECRET_ARN) + database: Database name (or from env AURORA_DATABASE) + region: AWS region (or from env AWS_REGION) + """ + self.cluster_arn = cluster_arn or os.environ.get("AURORA_CLUSTER_ARN") + self.secret_arn = secret_arn or os.environ.get("AURORA_SECRET_ARN") + self.database = database or os.environ.get("AURORA_DATABASE", "alex") + + if not self.cluster_arn or not self.secret_arn: + raise ValueError( + "Missing required Aurora configuration. " + "Set AURORA_CLUSTER_ARN and AURORA_SECRET_ARN environment variables." + ) + + self.region = os.environ.get("DEFAULT_AWS_REGION", "us-east-1") + self.client = boto3.client("rds-data", region_name=self.region) + + def execute(self, sql: str, parameters: List[Dict] = None) -> Dict: + """ + Execute a SQL statement + + Args: + sql: SQL statement to execute + parameters: Optional list of parameters for prepared statement + + Returns: + Response from Data API + """ + try: + kwargs = { + "resourceArn": self.cluster_arn, + "secretArn": self.secret_arn, + "database": self.database, + "sql": sql, + "includeResultMetadata": True, # Include column names + } + + if parameters: + kwargs["parameters"] = parameters + + response = self.client.execute_statement(**kwargs) + return response + + except ClientError as e: + logger.error(f"Database error: {e}") + raise + + def query(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """ + Execute a SELECT query and return results as list of dicts + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + List of dictionaries with column names as keys + """ + response = self.execute(sql, parameters) + + if "records" not in response: + return [] + + # Extract column names + columns = [col["name"] for col in response.get("columnMetadata", [])] + + # Convert records to dictionaries + results = [] + for record in response["records"]: + row = {} + for i, col in enumerate(columns): + value = self._extract_value(record[i]) + row[col] = value + results.append(row) + + return results + + def query_one(self, sql: str, parameters: List[Dict] = None) -> Optional[Dict]: + """ + Execute a SELECT query and return first result + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + Dictionary with column names as keys, or None if no results + """ + results = self.query(sql, parameters) + return results[0] if results else None + + def insert(self, table: str, data: Dict, returning: str = None) -> str: + """ + Insert a record into a table + + Args: + table: Table name + data: Dictionary of column names and values + returning: Column to return (e.g., 'id', 'clerk_user_id') + + Returns: + Value of returning column if specified + """ + columns = list(data.keys()) + placeholders = [] + + # Check if columns need type casting + for col in columns: + if isinstance(data[col], (dict, list)): + placeholders.append(f":{col}::jsonb") + elif isinstance(data[col], Decimal): + placeholders.append(f":{col}::numeric") + elif isinstance(data[col], date) and not isinstance(data[col], datetime): + placeholders.append(f":{col}::date") + elif isinstance(data[col], datetime): + placeholders.append(f":{col}::timestamp") + else: + placeholders.append(f":{col}") + + sql = f""" + INSERT INTO {table} ({", ".join(columns)}) + VALUES ({", ".join(placeholders)}) + """ + + # Add RETURNING clause if specified + if returning: + sql += f" RETURNING {returning}" + + parameters = self._build_parameters(data) + response = self.execute(sql, parameters) + + # Return value if RETURNING was used + if returning and response.get("records"): + return self._extract_value(response["records"][0][0]) + return None + + def update(self, table: str, data: Dict, where: str, where_params: Dict = None) -> int: + """ + Update records in a table + + Args: + table: Table name + data: Dictionary of columns to update + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of affected rows + """ + # Build SET clause with type casting where needed + set_parts = [] + for col, val in data.items(): + if isinstance(val, (dict, list)): + set_parts.append(f"{col} = :{col}::jsonb") + elif isinstance(val, Decimal): + set_parts.append(f"{col} = :{col}::numeric") + elif isinstance(val, date) and not isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::date") + elif isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::timestamp") + else: + set_parts.append(f"{col} = :{col}") + + set_clause = ", ".join(set_parts) + + sql = f""" + UPDATE {table} + SET {set_clause} + WHERE {where} + """ + + # Combine data and where parameters + all_params = {**data, **(where_params or {})} + parameters = self._build_parameters(all_params) + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def delete(self, table: str, where: str, where_params: Dict = None) -> int: + """ + Delete records from a table + + Args: + table: Table name + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of deleted rows + """ + sql = f"DELETE FROM {table} WHERE {where}" + parameters = self._build_parameters(where_params) if where_params else None + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def begin_transaction(self) -> str: + """Begin a database transaction""" + response = self.client.begin_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, database=self.database + ) + return response["transactionId"] + + def commit_transaction(self, transaction_id: str): + """Commit a database transaction""" + self.client.commit_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def rollback_transaction(self, transaction_id: str): + """Rollback a database transaction""" + self.client.rollback_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def _build_parameters(self, data: Dict) -> List[Dict]: + """Convert dictionary to Data API parameter format""" + if not data: + return [] + + parameters = [] + for key, value in data.items(): + param = {"name": key} + + if value is None: + param["value"] = {"isNull": True} + elif isinstance(value, bool): + param["value"] = {"booleanValue": value} + elif isinstance(value, int): + param["value"] = {"longValue": value} + elif isinstance(value, float): + param["value"] = {"doubleValue": value} + elif isinstance(value, Decimal): + param["value"] = {"stringValue": str(value)} + elif isinstance(value, (date, datetime)): + param["value"] = {"stringValue": value.isoformat()} + elif isinstance(value, dict): + param["value"] = {"stringValue": json.dumps(value)} + elif isinstance(value, list): + param["value"] = {"stringValue": json.dumps(value)} + else: + param["value"] = {"stringValue": str(value)} + + parameters.append(param) + + return parameters + + def _extract_value(self, field: Dict) -> Any: + """Extract value from Data API field response""" + if field.get("isNull"): + return None + elif "booleanValue" in field: + return field["booleanValue"] + elif "longValue" in field: + return field["longValue"] + elif "doubleValue" in field: + return field["doubleValue"] + elif "stringValue" in field: + value = field["stringValue"] + # Try to parse JSON if it looks like JSON + if value and value[0] in ["{", "["]: + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + elif "blobValue" in field: + return field["blobValue"] + else: + return None diff --git a/backend/agent_charter/src/models.py b/backend/agent_charter/src/models.py new file mode 100644 index 00000000..903e3594 --- /dev/null +++ b/backend/agent_charter/src/models.py @@ -0,0 +1,320 @@ +""" +Database models and query builders +""" + +from typing import Dict, List, Optional, Any +from datetime import datetime, date +from decimal import Decimal +from .client import DataAPIClient +from .schemas import ( + InstrumentCreate, UserCreate, AccountCreate, + PositionCreate, JobCreate, JobUpdate +) + + +class BaseModel: + """Base class for database models""" + + table_name = None + + def __init__(self, db: DataAPIClient): + self.db = db + if not self.table_name: + raise ValueError("table_name must be defined") + + def find_by_id(self, id: Any) -> Optional[Dict]: + """Find a record by ID""" + sql = f"SELECT * FROM {self.table_name} WHERE id = :id::uuid" + return self.db.query_one(sql, [{'name': 'id', 'value': {'stringValue': str(id)}}]) + + def find_all(self, limit: int = 100, offset: int = 0) -> List[Dict]: + """Find all records with pagination""" + sql = f"SELECT * FROM {self.table_name} LIMIT :limit OFFSET :offset" + params = [ + {'name': 'limit', 'value': {'longValue': limit}}, + {'name': 'offset', 'value': {'longValue': offset}} + ] + return self.db.query(sql, params) + + def create(self, data: Dict, returning: str = 'id') -> str: + """Create a new record""" + return self.db.insert(self.table_name, data, returning=returning) + + def update(self, id: Any, data: Dict) -> int: + """Update a record by ID""" + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': str(id)}) + + def delete(self, id: Any) -> int: + """Delete a record by ID""" + return self.db.delete(self.table_name, "id = :id::uuid", {'id': str(id)}) + + +class Users(BaseModel): + """Users table operations""" + table_name = 'users' + + def find_by_clerk_id(self, clerk_user_id: str) -> Optional[Dict]: + """Find user by Clerk ID""" + sql = f"SELECT * FROM {self.table_name} WHERE clerk_user_id = :clerk_id" + params = [{'name': 'clerk_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query_one(sql, params) + + def create_user(self, clerk_user_id: str, display_name: str = None, + years_until_retirement: int = None, + target_retirement_income: Decimal = None) -> str: + """Create a new user""" + data = { + 'clerk_user_id': clerk_user_id, + 'display_name': display_name, + 'years_until_retirement': years_until_retirement, + 'target_retirement_income': target_retirement_income + } + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + return self.db.insert(self.table_name, data, returning='clerk_user_id') + + +class Instruments(BaseModel): + """Instruments table operations""" + table_name = 'instruments' + + def find_all(self, limit: int = None, offset: int = 0) -> List[Dict]: + """Find all instruments - no limit by default for autocomplete""" + sql = f"SELECT * FROM {self.table_name} ORDER BY symbol" + return self.db.query(sql, []) + + def find_by_symbol(self, symbol: str) -> Optional[Dict]: + """Find instrument by symbol""" + sql = f"SELECT * FROM {self.table_name} WHERE symbol = :symbol" + params = [{'name': 'symbol', 'value': {'stringValue': symbol}}] + return self.db.query_one(sql, params) + + def create_instrument(self, instrument: InstrumentCreate) -> str: + """Create a new instrument with validation""" + # Validate using Pydantic + validated = instrument.model_dump() + + # Convert allocations to JSON strings for storage + data = { + 'symbol': validated['symbol'], + 'name': validated['name'], + 'instrument_type': validated['instrument_type'], + 'allocation_regions': validated['allocation_regions'], + 'allocation_sectors': validated['allocation_sectors'], + 'allocation_asset_class': validated['allocation_asset_class'] + } + + return self.db.insert(self.table_name, data, returning='symbol') + + def find_by_type(self, instrument_type: str) -> List[Dict]: + """Find all instruments of a specific type""" + sql = f"SELECT * FROM {self.table_name} WHERE instrument_type = :type ORDER BY symbol" + params = [{'name': 'type', 'value': {'stringValue': instrument_type}}] + return self.db.query(sql, params) + + def search(self, query: str) -> List[Dict]: + """Search instruments by symbol or name""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE LOWER(symbol) LIKE LOWER(:query) + OR LOWER(name) LIKE LOWER(:query) + ORDER BY symbol + LIMIT 20 + """ + params = [{'name': 'query', 'value': {'stringValue': f'%{query}%'}}] + return self.db.query(sql, params) + + +class Accounts(BaseModel): + """Accounts table operations""" + table_name = 'accounts' + + def find_by_user(self, clerk_user_id: str) -> List[Dict]: + """Find all accounts for a user""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + """ + params = [{'name': 'user_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query(sql, params) + + def create_account(self, clerk_user_id: str, account_name: str, + account_purpose: str = None, cash_balance: Decimal = Decimal('0'), + cash_interest: Decimal = Decimal('0')) -> str: + """Create a new account""" + data = { + 'clerk_user_id': clerk_user_id, + 'account_name': account_name, + 'account_purpose': account_purpose, + 'cash_balance': cash_balance, + 'cash_interest': cash_interest + } + return self.db.insert(self.table_name, data, returning='id') + + +class Positions(BaseModel): + """Positions table operations""" + table_name = 'positions' + + def find_by_account(self, account_id: str) -> List[Dict]: + """Find all positions in an account""" + sql = f""" + SELECT p.*, i.name as instrument_name, i.instrument_type, i.current_price + FROM {self.table_name} p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + ORDER BY p.symbol + """ + params = [{'name': 'account_id', 'value': {'stringValue': account_id}}] + return self.db.query(sql, params) + + def get_portfolio_value(self, account_id: str) -> Dict: + """Calculate total portfolio value using current prices from instruments table""" + sql = """ + SELECT + COUNT(DISTINCT p.symbol) as num_positions, + SUM(p.quantity * i.current_price) as total_value, + SUM(p.quantity) as total_shares + FROM positions p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}} + ] + result = self.db.query_one(sql, params) + if result: + return { + 'num_positions': result.get('num_positions', 0), + 'total_value': float(result.get('total_value', 0)) if result.get('total_value') else 0, + 'total_shares': float(result.get('total_shares', 0)) if result.get('total_shares') else 0 + } + return {'num_positions': 0, 'total_value': 0, 'total_shares': 0} + + def add_position(self, account_id: str, symbol: str, quantity: Decimal) -> str: + """Add or update a position""" + # Use UPSERT to handle existing positions + sql = """ + INSERT INTO positions (account_id, symbol, quantity, as_of_date) + VALUES (:account_id::uuid, :symbol, :quantity::numeric, :as_of_date::date) + ON CONFLICT (account_id, symbol) + DO UPDATE SET + quantity = EXCLUDED.quantity, + as_of_date = EXCLUDED.as_of_date, + updated_at = NOW() + RETURNING id + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}}, + {'name': 'symbol', 'value': {'stringValue': symbol}}, + {'name': 'quantity', 'value': {'stringValue': str(quantity)}}, + {'name': 'as_of_date', 'value': {'stringValue': date.today().isoformat()}} + ] + response = self.db.execute(sql, params) + if response.get('records'): + return response['records'][0][0].get('stringValue') + return None + + +class Jobs(BaseModel): + """Jobs table operations""" + table_name = 'jobs' + + def create_job(self, clerk_user_id: str, job_type: str, + request_payload: Dict = None) -> str: + """Create a new job""" + data = { + 'clerk_user_id': clerk_user_id, + 'job_type': job_type, + 'status': 'pending', + 'request_payload': request_payload + } + return self.db.insert(self.table_name, data, returning='id') + + def update_status(self, job_id: str, status: str, error_message: str = None) -> int: + """Update job status""" + data = {'status': status} + + if status == 'running': + data['started_at'] = datetime.utcnow() + elif status in ['completed', 'failed', 'max_tokens_exceeded']: + data['completed_at'] = datetime.utcnow() + + if error_message: + data['error_message'] = error_message + + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_report(self, job_id: str, report_payload: Dict) -> int: + """Update job with Reporter agent's analysis""" + data = {'report_payload': report_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_charts(self, job_id: str, charts_payload: Dict) -> int: + """Update job with Charter agent's visualization data""" + data = {'charts_payload': charts_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_retirement(self, job_id: str, retirement_payload: Dict) -> int: + """Update job with Retirement agent's projections""" + data = {'retirement_payload': retirement_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_summary(self, job_id: str, summary_payload: Dict) -> int: + """Update job with Planner's final summary""" + data = {'summary_payload': summary_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def find_by_user(self, clerk_user_id: str, status: str = None, + limit: int = 20) -> List[Dict]: + """Find jobs for a user""" + if status: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id AND status = :status + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'status', 'value': {'stringValue': status}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + else: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + + return self.db.query(sql, params) + + +class Database: + """Main database interface providing access to all models""" + + def __init__(self, cluster_arn: str = None, secret_arn: str = None, + database: str = None, region: str = None): + """Initialize database with all model classes""" + self.client = DataAPIClient(cluster_arn, secret_arn, database, region) + + # Initialize all models + self.users = Users(self.client) + self.instruments = Instruments(self.client) + self.accounts = Accounts(self.client) + self.positions = Positions(self.client) + self.jobs = Jobs(self.client) + + def execute_raw(self, sql: str, parameters: List[Dict] = None) -> Dict: + """Execute raw SQL for complex queries""" + return self.client.execute(sql, parameters) + + def query_raw(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """Execute raw SELECT query""" + return self.client.query(sql, parameters) \ No newline at end of file diff --git a/backend/agent_charter/src/schemas.py b/backend/agent_charter/src/schemas.py new file mode 100644 index 00000000..44f16514 --- /dev/null +++ b/backend/agent_charter/src/schemas.py @@ -0,0 +1,284 @@ +""" +Pydantic schemas for data validation and LLM tool interfaces +These models serve as both database validation and LLM structured output schemas +""" + +from typing import Dict, Literal, Optional, List +from pydantic import BaseModel, Field, field_validator +from decimal import Decimal +from datetime import date, datetime + + +# Define allowed values as Literals for LLM compatibility +RegionType = Literal[ + "north_america", + "europe", + "asia", + "latin_america", + "africa", + "middle_east", + "oceania", + "global", + "international", # For mixed non-US +] + +AssetClassType = Literal[ + "equity", "fixed_income", "real_estate", "commodities", "cash", "alternatives" +] + +SectorType = Literal[ + "technology", + "healthcare", + "financials", + "consumer_discretionary", + "consumer_staples", + "industrials", + "energy", + "materials", + "utilities", + "real_estate", + "communication", + "treasury", + "corporate", + "mortgage", + "government_related", + "commodities", + "diversified", + "other", +] + +InstrumentType = Literal["etf", "mutual_fund", "stock", "bond", "bond_fund", "commodity", "reit"] + +JobType = Literal[ + "portfolio_analysis", + "rebalance_recommendation", + "retirement_projection", + "risk_assessment", + "tax_optimization", + "instrument_research", +] + +JobStatus = Literal["pending", "running", "completed", "failed", "max_tokens_exceeded"] + +AccountType = Literal[ + "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" +] + + +class AllocationDict(BaseModel): + """Base class for allocation dictionaries ensuring they sum to 100""" + + @field_validator("*", mode="after") + def validate_sum(cls, v, info): + """Ensure allocation percentages sum to 100""" + if isinstance(v, dict): + total = sum(v.values()) + if abs(total - 100) > 3: # Allow small floating point errors + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class RegionAllocation(BaseModel): + """Geographic allocation of an instrument""" + + allocations: Dict[RegionType, float] = Field( + description="Percentage allocation by geographic region. Must sum to 100.", + example={"north_america": 60, "europe": 25, "asia": 15}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Region allocations must sum to 100, got {total}") + return v + + +class AssetClassAllocation(BaseModel): + """Asset class allocation of an instrument""" + + allocations: Dict[AssetClassType, float] = Field( + description="Percentage allocation by asset class. Must sum to 100.", + example={"equity": 80, "fixed_income": 20}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Asset class allocations must sum to 100, got {total}") + return v + + +class SectorAllocation(BaseModel): + """Sector allocation of an instrument""" + + allocations: Dict[SectorType, float] = Field( + description="Percentage allocation by market sector. Must sum to 100.", + example={"technology": 30, "healthcare": 25, "financials": 20, "other": 25}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Sector allocations must sum to 100, got {total}") + return v + + +class InstrumentCreate(BaseModel): + """Schema for creating a new instrument - suitable for LLM tool input""" + + symbol: str = Field( + description="The ticker symbol of the instrument (e.g., 'SPY', 'BND')", + min_length=1, + max_length=20, + ) + name: str = Field(description="Full name of the instrument", min_length=1, max_length=255) + instrument_type: InstrumentType = Field(description="The type of financial instrument") + current_price: Optional[Decimal] = Field( + None, + description="Current price of the instrument for portfolio calculations", + ge=0, + le=999999, + ) + allocation_regions: Dict[RegionType, float] = Field( + description="Geographic allocation percentages. Must sum to 100.", + example={"north_america": 100}, + ) + allocation_sectors: Dict[SectorType, float] = Field( + description="Sector allocation percentages. Must sum to 100.", + example={"technology": 40, "healthcare": 30, "financials": 30}, + ) + allocation_asset_class: Dict[AssetClassType, float] = Field( + description="Asset class allocation percentages. Must sum to 100.", example={"equity": 100} + ) + + @field_validator("allocation_regions", "allocation_sectors", "allocation_asset_class") + def validate_allocations(cls, v): + """Ensure all allocations sum to 100""" + if not v: + raise ValueError("Allocation cannot be empty") + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class InstrumentResponse(InstrumentCreate): + """Schema for instrument responses from database""" + + created_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + """Schema for creating a user - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="Unique identifier from Clerk authentication system") + display_name: Optional[str] = Field(None, description="User's display name", max_length=255) + years_until_retirement: Optional[int] = Field( + None, description="Number of years until the user plans to retire", ge=0, le=100 + ) + target_retirement_income: Optional[Decimal] = Field( + None, description="Annual income goal in retirement (in dollars)", ge=0, decimal_places=2 + ) + asset_class_targets: Optional[Dict[AssetClassType, float]] = Field( + default={"equity": 70, "fixed_income": 30}, + description="Target allocation percentages for rebalancing. Must sum to 100.", + ) + region_targets: Optional[Dict[RegionType, float]] = Field( + default={"north_america": 50, "international": 50}, + description="Target geographic allocation for rebalancing. Must sum to 100.", + ) + + +class AccountCreate(BaseModel): + """Schema for creating an account - suitable for LLM tool input""" + + account_name: str = Field( + description="Name of the account (e.g., '401k', 'Roth IRA')", min_length=1, max_length=255 + ) + account_purpose: Optional[str] = Field(None, description="Purpose or goal of this account") + cash_balance: Decimal = Field( + default=Decimal("0"), + description="Uninvested cash balance in the account", + ge=0, + decimal_places=2, + ) + cash_interest: Decimal = Field( + default=Decimal("0"), + description="Annual interest rate on cash (e.g., 0.045 for 4.5%)", + ge=0, + le=1, + decimal_places=4, + ) + + +class PositionCreate(BaseModel): + """Schema for creating a position - suitable for LLM tool input""" + + account_id: str = Field(description="UUID of the account holding this position") + symbol: str = Field(description="Ticker symbol of the instrument", min_length=1, max_length=20) + quantity: Decimal = Field( + description="Number of shares (supports fractional shares)", gt=0, decimal_places=8 + ) + as_of_date: Optional[date] = Field( + default_factory=date.today, description="Date of this position snapshot" + ) + + +class JobCreate(BaseModel): + """Schema for creating a job - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="User requesting this job") + job_type: JobType = Field(description="Type of analysis or operation to perform") + request_payload: Optional[Dict] = Field(None, description="Input parameters for the job") + + +class JobUpdate(BaseModel): + """Schema for updating job status - suitable for LLM tool output""" + + status: JobStatus = Field(description="Current status of the job") + result_payload: Optional[Dict] = Field(None, description="Results of the completed job") + error_message: Optional[str] = Field(None, description="Error details if job failed") + + +class PortfolioAnalysis(BaseModel): + """Schema for portfolio analysis results - LLM structured output""" + + total_value: Decimal = Field(description="Total portfolio value in dollars", decimal_places=2) + asset_allocation: Dict[AssetClassType, float] = Field( + description="Current asset class allocation percentages" + ) + region_allocation: Dict[RegionType, float] = Field( + description="Current geographic allocation percentages" + ) + sector_allocation: Dict[SectorType, float] = Field( + description="Current sector allocation percentages" + ) + risk_score: int = Field( + description="Risk score from 1 (conservative) to 10 (aggressive)", ge=1, le=10 + ) + recommendations: List[str] = Field( + description="List of actionable recommendations for the portfolio" + ) + + +class RebalanceRecommendation(BaseModel): + """Schema for rebalancing recommendations - LLM structured output""" + + current_allocation: Dict[str, float] = Field( + description="Current allocation by instrument symbol" + ) + target_allocation: Dict[str, float] = Field( + description="Recommended target allocation by symbol" + ) + trades: List[Dict] = Field( + description="List of trades needed to rebalance", + example=[ + {"symbol": "SPY", "action": "sell", "quantity": 10}, + {"symbol": "BND", "action": "buy", "quantity": 50}, + ], + ) + rationale: str = Field(description="Explanation of why these changes are recommended") diff --git a/backend/agent_charter/test_simple.py b/backend/agent_charter/test_simple.py new file mode 100644 index 00000000..aa0e678d --- /dev/null +++ b/backend/agent_charter/test_simple.py @@ -0,0 +1,268 @@ +""" +Test the Charter Agent with database integration (simple test). +""" + +import os +import json +import uuid +import asyncio +from typing import Dict, Any + +# Import database +from src import Database + +# We'll import the agent entry point within the test function + +def create_test_portfolio_data() -> Dict[str, Any]: + """Create test portfolio data for charter agent.""" + return { + "user_id": "test_user_charter", + "job_id": None, # Will be set after database job creation + "accounts": [ + { + "id": "charter_acc1", + "name": "401(k)", + "type": "401k", + "cash_balance": 5000.0, + "positions": [ + { + "symbol": "SPY", + "quantity": 100.0, + "instrument": { + "symbol": "SPY", + "name": "SPDR S&P 500 ETF", + "current_price": 450.0, + "allocation_asset_class": {"equity": 100}, + "allocation_regions": {"north_america": 100}, + "allocation_sectors": { + "technology": 30, + "healthcare": 15, + "financials": 15, + "consumer_discretionary": 20, + "industrials": 20 + } + } + }, + { + "symbol": "BND", + "quantity": 50.0, + "instrument": { + "symbol": "BND", + "name": "Vanguard Total Bond Market ETF", + "current_price": 75.0, + "allocation_asset_class": {"fixed_income": 100}, + "allocation_regions": {"north_america": 100}, + "allocation_sectors": {"government": 70, "corporate": 30} + } + } + ] + }, + { + "id": "charter_acc2", + "name": "Roth IRA", + "type": "roth_ira", + "cash_balance": 2500.0, + "positions": [ + { + "symbol": "VTI", + "quantity": 75.0, + "instrument": { + "symbol": "VTI", + "name": "Vanguard Total Stock Market ETF", + "current_price": 220.0, + "allocation_asset_class": {"equity": 100}, + "allocation_regions": {"north_america": 100}, + "allocation_sectors": { + "technology": 25, + "healthcare": 14, + "financials": 13, + "consumer_discretionary": 12, + "industrials": 9, + "consumer_staples": 6, + "energy": 4, + "real_estate": 4, + "utilities": 3, + "materials": 3, + "telecommunications": 7 + } + } + } + ] + } + ] + } + +def test_charter_agent(): + """Test the charter agent with portfolio data.""" + print("🎨 Testing Charter Agent...") + + # Create test data + portfolio_data = create_test_portfolio_data() + job_id = portfolio_data["job_id"] # Will be None initially + user_id = portfolio_data["user_id"] + + print(f"📊 Job ID: {job_id if job_id else 'To be generated'}") + print(f"👤 User ID: {user_id}") + + # Calculate total portfolio value for context + total_value = 0.0 + for account in portfolio_data["accounts"]: + total_value += float(account.get("cash_balance", 0)) + for position in account["positions"]: + quantity = float(position["quantity"]) + price = float(position["instrument"]["current_price"]) + total_value += quantity * price + + print(f"💰 Total Portfolio Value: ${total_value:,.2f}") + + try: + # Initialize database + db = Database() + print("📊 Database connected") + + # Create or find user + try: + user = db.users.find_by_clerk_id(user_id) + if not user: + # Use the correct method for creating users + user_id_created = db.users.create_user( + clerk_user_id=user_id, + display_name='Charter Test', + years_until_retirement=25 + ) + print(f"👤 Created test user: {user_id_created}") + else: + print(f"👤 Found existing user: {user_id}") + except Exception as e: + print(f"⚠️ User setup warning: {e}") + + # Create job entry + try: + # Use the correct method for creating jobs + created_job_id = db.jobs.create_job( + clerk_user_id=user_id, + job_type='portfolio_analysis', + request_payload={'test': 'charter agent charts generation'} + ) + print(f"💼 Created job with auto-generated ID: {created_job_id}") + # Update our job_id to use the database-generated one + job_id = created_job_id + portfolio_data["job_id"] = str(created_job_id) # Update string version too + except Exception as e: + print(f"⚠️ Job creation warning: {e}") + + # Run the charter agent (full agent with database saving) + print("\n🎨 Running Charter Agent with database integration...") + + # Prepare event for the agent + event = { + 'job_id': job_id, + 'portfolio_data': portfolio_data, + 'user_id': user_id + } + + # Import the agent entry point + from agent import chart_maker_agent + + # Run the agent asynchronously + import asyncio + result = asyncio.run(chart_maker_agent(event)) + + print("📊 Charter Agent Result:") + print("=" * 50) + + # Debug: Show raw result first + print(f"🔍 Raw result type: {type(result)}") + print(f"🔍 Raw result (first 500 chars): {str(result)[:500]}") + + # Try to parse and format the JSON result + try: + parsed_result = json.loads(result) + print(f"🔍 Parsed result keys: {list(parsed_result.keys()) if isinstance(parsed_result, dict) else 'Not a dict'}") + + if "error" in parsed_result: + print(f"❌ Error: {parsed_result['error']}") + elif "success" in parsed_result and parsed_result.get("success"): + # Handle the success response from chart_maker_agent + print(f"✅ {parsed_result.get('message', 'Charts generated successfully')}") + print(f"📊 Charts generated: {parsed_result.get('charts_generated', 'unknown')}") + chart_keys = parsed_result.get('chart_keys', []) + if chart_keys: + print(f"🔑 Chart keys: {chart_keys}") + + # Since charts were processed and saved, skip detailed chart display + # The database verification below will show the actual saved data + + elif "charts" in parsed_result: + # Handle the response from create_agent_and_run (if called directly) + charts = parsed_result["charts"] + print(f"✅ Generated {len(charts)} charts:") + + for i, chart in enumerate(charts, 1): + print(f"\n📈 Chart {i}: {chart.get('title', 'Untitled')}") + print(f" Type: {chart.get('type', 'unknown')}") + print(f" Key: {chart.get('key', 'unknown')}") + print(f" Description: {chart.get('description', 'No description')}") + + data_points = chart.get('data', []) + print(f" Data Points: {len(data_points)}") + + # Show first few data points + for j, point in enumerate(data_points[:3]): + name = point.get('name', 'Unknown') + value = point.get('value', 0) + color = point.get('color', 'No color') + print(f" {j+1}. {name}: ${value:,.2f} ({color})") + + if len(data_points) > 3: + print(f" ... and {len(data_points) - 3} more") + + print(f"\n✅ Charter Agent successfully generated {len(charts)} charts") + else: + print("❓ Unexpected result format") + print(f"� Available keys: {list(parsed_result.keys()) if isinstance(parsed_result, dict) else 'Not a dict'}") + print(f"� Full result: {parsed_result}") + print(result) + + except json.JSONDecodeError as e: + print(f"❌ Failed to parse JSON result: {e}") + print("Raw result:") + print(result) + + # Verify charts were saved to database (regardless of response format) + print("\n🔍 Verifying charts saved to database...") + try: + # Query the database to check if charts were saved + updated_job = db.jobs.find_by_id(job_id) + if updated_job and updated_job.get('charts_payload'): + charts_payload = updated_job['charts_payload'] + print(f"✅ Charts found in database!") + print(f"📊 Number of chart keys in database: {len(charts_payload)}") + print(f"🔑 Chart keys: {list(charts_payload.keys())}") + + # Verify each chart has expected structure + for key, chart_data in charts_payload.items(): + chart_type = chart_data.get('type', 'unknown') + data_points = len(chart_data.get('data', [])) if isinstance(chart_data.get('data'), list) else 0 + print(f" 📈 {key}: type={chart_type}, data_points={data_points}") + + print("✅ Database verification successful!") + else: + print("❌ No charts found in database - save operation may have failed") + if updated_job: + print(f"📋 Job found but charts_payload is: {updated_job.get('charts_payload', 'missing')}") + else: + print(f"📋 Job {job_id} not found in database") + except Exception as db_check_error: + print(f"❌ Error checking database: {db_check_error}") + + print("\n" + "=" * 50) + print("✅ Charter Agent test completed") + + except Exception as e: + print(f"❌ Error during Charter Agent test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_charter_agent() \ No newline at end of file diff --git a/backend/agent_charter/utils.py b/backend/agent_charter/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/agent_charter/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/agent_planner/.bedrock_agentcore.yaml b/backend/agent_planner/.bedrock_agentcore.yaml new file mode 100644 index 00000000..8cec31fd --- /dev/null +++ b/backend/agent_planner/.bedrock_agentcore.yaml @@ -0,0 +1,41 @@ +default_agent: planner +agents: + planner: + name: planner + entrypoint: /Users/fotis/Documents/CV/Learning/AI in production/alex/backend/agent_planner/agent.py + platform: linux/arm64 + container_runtime: docker + source_path: null + aws: + execution_role: arn:aws:iam::717174128108:role/agentcore-planner-role + execution_role_auto_create: false + account: '717174128108' + region: us-east-1 + ecr_repository: 717174128108.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-planner + ecr_auto_create: false + network_configuration: + network_mode: PUBLIC + network_mode_config: null + protocol_configuration: + server_protocol: HTTP + observability: + enabled: true + bedrock_agentcore: + agent_id: planner-qZaeHRFwDf + agent_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:runtime/planner-qZaeHRFwDf + agent_session_id: null + codebuild: + project_name: bedrock-agentcore-planner-builder + execution_role: arn:aws:iam::717174128108:role/AmazonBedrockAgentCoreSDKCodeBuild-us-east-1-244b843506 + source_bucket: bedrock-agentcore-codebuild-sources-717174128108-us-east-1 + memory: + mode: STM_ONLY + memory_id: planner_mem-IlV4TdEpHe + memory_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:memory/planner_mem-IlV4TdEpHe + memory_name: planner_mem + event_expiry_days: 30 + first_invoke_memory_check_done: false + was_created_by_toolkit: false + authorizer_configuration: null + request_header_configuration: null + oauth_configuration: null diff --git a/backend/agent_planner/.dockerignore b/backend/agent_planner/.dockerignore new file mode 100644 index 00000000..bf13996c --- /dev/null +++ b/backend/agent_planner/.dockerignore @@ -0,0 +1,69 @@ +# Build artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Python cache +__pycache__/ +__pycache__* +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +.env +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +.coverage* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.mypy_cache/ +.ruff_cache/ + +# Development +*.log +*.bak +*.swp +*.swo +*~ +.DS_Store + +# IDEs +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Project specific +tests/ + +# Bedrock AgentCore specific - keep config but exclude runtime files +.bedrock_agentcore.yaml +.dockerignore +.bedrock_agentcore/ + +# Keep wheelhouse for offline installations +# wheelhouse/ diff --git a/backend/agent_planner/.gitignore b/backend/agent_planner/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_planner/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_planner/Dockerfile b/backend/agent_planner/Dockerfile new file mode 100644 index 00000000..2b112863 --- /dev/null +++ b/backend/agent_planner/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app + +# All environment variables in one layer +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + AWS_REGION=us-east-1 \ + AWS_DEFAULT_REGION=us-east-1 \ + BEDROCK_AGENTCORE_MEMORY_ID=planner_mem-IlV4TdEpHe \ + BEDROCK_AGENTCORE_MEMORY_NAME=planner_mem + + + +COPY requirements.txt requirements.txt +# Install from requirements file +RUN uv pip install -r requirements.txt + + + + +RUN uv pip install aws-opentelemetry-distro>=0.10.1 + + +# Signal that this is running in Docker for host binding logic +ENV DOCKER_CONTAINER=1 + +# Create non-root user +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 9000 +EXPOSE 8000 +EXPOSE 8080 + +# Copy entire project (respecting .dockerignore) +COPY . . + +# Use the full module path + +CMD ["opentelemetry-instrument", "python", "-m", "agent"] diff --git a/backend/agent_planner/agent.py b/backend/agent_planner/agent.py new file mode 100644 index 00000000..65b1a46c --- /dev/null +++ b/backend/agent_planner/agent.py @@ -0,0 +1,600 @@ +""" +Financial Planner Orchestrator Agent - AgentCore Implementation + +This agent coordinates portfolio analysis across specialized agents using +the invoke_agent_with_boto3 pattern for orchestration. + +Version: Updated 2025-10-28 +""" + +import json +import os +import asyncio +from datetime import datetime +from typing import Dict, Any, Optional + +# Load environment variables from SSM at startup +# import sys +# sys.path.append('/opt/python') # Add common layer path if available +from utils import load_env_from_ssm +load_env_from_ssm() + # Fallback to local .env file +print(f"🔍 DEBUG: Starting imports after environment loading") +from strands import Agent, tool + +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp +# Import parent directory utils +import sys +import os + +# Add current directory to Python path for src imports +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +print(f"🔍 DEBUG: About to import tools") +from tools import get_agent_arn, invoke_agent_with_boto3 +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'database'))) + +print(f"🔍 DEBUG: About to import Database") +from src import Database + +print(f"🔍 DEBUG: About to import job_progress") +# Add job progress tracking +sys.path.append(os.path.dirname(__file__)) +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + + + +def add_job_progress(db, job_id, message, agent=None, details=None): + print(f"🔍 DEBUG: Adding job progress: job_id={job_id}, message={message}, agent={agent}, details={details}") + + +def load_portfolio_summary(job_id: str, db: Database) -> Dict[str, Any]: + """Load basic portfolio summary statistics.""" + try: + job = db.jobs.find_by_id(job_id) + if not job: + raise ValueError(f"Job {job_id} not found") + + user_id = job["clerk_user_id"] + user = db.users.find_by_clerk_id(user_id) + if not user: + raise ValueError(f"User {user_id} not found") + + accounts = db.accounts.find_by_user(user_id) + + # Calculate simple summary statistics + total_value = 0.0 + total_positions = 0 + total_cash = 0.0 + + for account in accounts: + total_cash += float(account.get("cash_balance", 0)) + positions = db.positions.find_by_account(account["id"]) + total_positions += len(positions) + + # Add position values + for position in positions: + instrument = db.instruments.find_by_symbol(position["symbol"]) + if instrument and instrument.get("current_price"): + price = float(instrument["current_price"]) + quantity = float(position["quantity"]) + total_value += price * quantity + + total_value += total_cash + + # Return only summary statistics + return { + "total_value": total_value, + "num_accounts": len(accounts), + "num_positions": total_positions, + "years_until_retirement": user.get("years_until_retirement", 30), + "target_retirement_income": float(user.get("target_retirement_income", 80000)) + } + + except Exception as e: + + raise + +print(f"✅ DEBUG: load_portfolio_summary function defined") + +print(f"🔍 DEBUG: About to define @tool decorated functions") + +@tool +async def invoke_reporter_agent(job_id: str) -> str: + """ + Invoke the Report Writer agent to generate portfolio analysis narrative. + + Args: + job_id: The job ID for the analysis + + Returns: + Analysis result message + """ + try: + # Get database instance + print(f"🔍 DEBUG: invoke_reporter_agent called with job_id: {job_id}") + db = Database() + + agent_arn = get_agent_arn("reporter") + if not agent_arn: + add_job_progress(db, job_id, "Error: Could not find Reporter agent ARN", "planner") + return "Error: Could not find Reporter agent ARN" + + session_id = f"planner-{job_id}-{int(datetime.now().timestamp())}" + + # Load portfolio data to pass to reporter agent + try: + job = db.jobs.find_by_id(job_id) + if job and job.get("clerk_user_id"): + user_id = job["clerk_user_id"] + accounts = db.accounts.find_by_user(user_id) + + portfolio_data = {"accounts": []} + for account in accounts: + positions = db.positions.find_by_account(account["id"]) + account_data = { + "id": account["id"], + "name": account.get("account_name", ""), + "cash_balance": float(account.get("cash_balance", 0)), + "positions": [] + } + + for position in positions: + instrument = db.instruments.find_by_symbol(position["symbol"]) + position_data = { + "symbol": position["symbol"], + "quantity": float(position["quantity"]), + "instrument": instrument or {} + } + account_data["positions"].append(position_data) + + portfolio_data["accounts"].append(account_data) + + payload = {"job_id": job_id, "portfolio_data": portfolio_data} + else: + payload = {"job_id": job_id} + except Exception as e: + payload = {"job_id": job_id} + + start_ts = datetime.now() + add_job_progress(db, job_id, f"Invoking Reporter agent", "planner") + result = await invoke_agent_with_boto3(agent_arn, session_id, payload) + duration = (datetime.now() - start_ts).total_seconds() + + preview = (result or "").strip() + if len(preview) > 300: + preview = preview[:300] + "..." + add_job_progress(db, job_id, f"Reporter completed in {duration:.1f}s", "planner") + + return f"Reporter agent completed: {result}" + + except Exception as e: + db = Database() + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + add_job_progress(db, job_id, f"Reporter stopped: Reached maximum token limit", "planner") + return f"Reporter agent stopped due to max tokens limit: {str(e)}" + else: + add_job_progress(db, job_id, f"Reporter failed: {str(e)}", "planner") + return f"Reporter agent failed: {str(e)}" + +print(f"✅ DEBUG: invoke_reporter_agent function defined") + +@tool +async def invoke_charter_agent(job_id: str) -> str: + """ + Invoke the Chart Maker agent to create portfolio visualizations. + + Args: + job_id: The job ID for the analysis + + Returns: + Chart creation result message + """ + try: + print(f"🔍 DEBUG: invoke_charter_agent called with job_id: {job_id}") + # Get database instance + db = Database() + + agent_arn = get_agent_arn("charter") + if not agent_arn: + add_job_progress(db, job_id, "Error: Could not find Charter agent ARN", "planner") + return "Error: Could not find Charter agent ARN" + + session_id = f"planner-{job_id}-{int(datetime.now().timestamp())}" + # Charter benefits from portfolio_data; planner can optionally load it later + payload = {"job_id": job_id} + + start_ts = datetime.now() + add_job_progress(db, job_id, f"Invoking Charter agent", "planner") + result = await invoke_agent_with_boto3(agent_arn, session_id, payload) + duration = (datetime.now() - start_ts).total_seconds() + + preview = (result or "").strip() + if len(preview) > 300: + preview = preview[:300] + "..." + add_job_progress(db, job_id, f"Charter completed in {duration:.1f}s", "planner") + + return f"Charter agent completed: {result}" + + except Exception as e: + db = Database() + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + add_job_progress(db, job_id, f"Charter stopped: Reached maximum token limit", "planner") + return f"Charter agent stopped due to max tokens limit: {str(e)}" + else: + add_job_progress(db, job_id, f"Charter failed: {str(e)}", "planner") + return f"Charter agent failed: {str(e)}" + +print(f"✅ DEBUG: invoke_charter_agent function defined") + +@tool +async def invoke_retirement_agent(job_id: str) -> str: + """ + Invoke the Retirement Specialist agent for retirement projections. + + Args: + job_id: The job ID for the analysis + + Returns: + Retirement analysis result message + """ + try: + print(f"🔍 DEBUG: invoke_retirement_agent called with job_id: {job_id}") + # Get database instance + db = Database() + + agent_arn = get_agent_arn("retirement") + if not agent_arn: + add_job_progress(db, job_id, "Error: Could not find Retirement agent ARN", "planner") + return "Error: Could not find Retirement agent ARN" + + session_id = f"planner-{job_id}-{int(datetime.now().timestamp())}" + payload = {"job_id": job_id} + + start_ts = datetime.now() + add_job_progress(db, job_id, f"Invoking Retirement agent", "planner") + result = await invoke_agent_with_boto3(agent_arn, session_id, payload) + duration = (datetime.now() - start_ts).total_seconds() + + preview = (result or "").strip() + if len(preview) > 300: + preview = preview[:300] + "..." + add_job_progress(db, job_id, f"Retirement completed in {duration:.1f}s", "planner") + + return f"Retirement agent completed: {result}" + + except Exception as e: + db = Database() + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + add_job_progress(db, job_id, f"Retirement stopped: Reached maximum token limit", "planner") + return f"Retirement agent stopped due to max tokens limit: {str(e)}" + else: + add_job_progress(db, job_id, f"Retirement failed: {str(e)}", "planner") + return f"Retirement agent failed: {str(e)}" + +print(f"✅ DEBUG: invoke_retirement_agent function defined") + +@tool +async def invoke_tagger_agent(instruments: list) -> str: + """ + Invoke the Tagger agent to classify financial instruments. + + Args: + instruments: List of instruments to classify + + Returns: + Classification result message + """ + try: + print(f"🔍 DEBUG: invoke_tagger_agent called with {len(instruments)} instruments") + + agent_arn = get_agent_arn("tagger") + if not agent_arn: + return "Error: Could not find Tagger agent ARN" + + session_id = f"planner-tagger-{int(datetime.now().timestamp())}" + payload = {"instruments": instruments} + + start_ts = datetime.now() + result = await invoke_agent_with_boto3(agent_arn, session_id, payload) + duration = (datetime.now() - start_ts).total_seconds() + + preview = (result or "").strip() + if len(preview) > 300: + preview = preview[:300] + "..." + + return f"Tagger agent completed: {result}" + + except Exception as e: + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + return f"Tagger agent stopped due to max tokens limit: {str(e)}" + else: + return f"Tagger agent failed: {str(e)}" + +print(f"✅ DEBUG: invoke_tagger_agent function defined") + +print(f"🔍 DEBUG: About to define handle_missing_instruments function") + +async def handle_missing_instruments(job_id: str, db: Database) -> None: + """ + Check for and tag any instruments missing allocation data. + This is done automatically before the agent runs. + """ + # Get job and portfolio data + job = db.jobs.find_by_id(job_id) + if not job: + return + + user_id = job["clerk_user_id"] + accounts = db.accounts.find_by_user(user_id) + + missing = [] + for account in accounts: + positions = db.positions.find_by_account(account["id"]) + + for position in positions: + symbol = position["symbol"] + instrument = db.instruments.find_by_symbol(symbol) + if instrument: + has_allocations = bool( + instrument.get("allocation_regions") + and instrument.get("allocation_sectors") + and instrument.get("allocation_asset_class") + ) + if not has_allocations: + missing.append( + {"symbol": position["symbol"], "name": instrument.get("name", "")} + ) + else: + missing.append({"symbol": position["symbol"], "name": ""}) + + if missing: + # Use the tagger tool to classify missing instruments + tagger_result = await invoke_tagger_agent(missing) + +print(f"✅ DEBUG: handle_missing_instruments function defined") + +print(f"🔍 DEBUG: About to define create_agent_and_run function") + +def create_agent_and_run(job_id: str) -> str: + """ + Create and run the orchestrator agent for portfolio analysis coordination. + + Args: + job_id: The portfolio analysis job ID + + Returns: + Final orchestration result + """ + try: + db = Database() + db.jobs.update_status(job_id, 'running') + load_env_from_ssm() + + handle_missing_instruments(job_id, db) + portfolio_summary = load_portfolio_summary(job_id, db) + + # Get model configuration and create model + model_id = os.environ.get("BEDROCK_MODEL_ID", "us.anthropic.claude-3-haiku-20240307-v1:0") + print(f"🤖 DEBUG: Using Bedrock model_id={model_id} for job {job_id}") + print(f"🔧 DEBUG: Creating BedrockModel instance for job {job_id}") + model = BedrockModel(model_id=model_id) + print(f"✅ DEBUG: BedrockModel created successfully for job {job_id}") + + # Deterministic kickoff: invoke core agents based on summary, independent of LLM tool-calling + print(f"🎯 DEBUG: Starting deterministic kickoff of downstream agents for job {job_id}") + try: + # Always reporter if positions > 0 + if portfolio_summary.get('num_positions', 0) > 0: + print(f"📊 DEBUG: Invoking Reporter agent (positions > 0) for job {job_id}") + invoke_reporter_agent(job_id) + print(f"✅ DEBUG: Reporter agent completed for job {job_id}") + else: + print(f"⏭️ DEBUG: Skipping Reporter agent (no positions) for job {job_id}") + + # Charter if at least 2 positions + if portfolio_summary.get('num_positions', 0) >= 2: + print(f"📋 DEBUG: Invoking Charter agent (positions >= 2) for job {job_id}") + invoke_charter_agent(job_id) + print(f"✅ DEBUG: Charter agent completed for job {job_id}") + else: + print(f"⏭️ DEBUG: Skipping Charter agent (positions < 2) for job {job_id}") + + # Retirement if years_until_retirement > 0 + if portfolio_summary.get('years_until_retirement', 0) > 0: + print(f"🏖️ DEBUG: Invoking Retirement agent (years_until_retirement > 0) for job {job_id}") + invoke_retirement_agent(job_id) + print(f"✅ DEBUG: Retirement agent completed for job {job_id}") + else: + print(f"⏭️ DEBUG: Skipping Retirement agent (years_until_retirement <= 0) for job {job_id}") + + print(f"🎉 DEBUG: All deterministic agents completed for job {job_id}") + except Exception as e: + print(f"❌ DEBUG: Error during deterministic kickoff for job {job_id}: {e}") + + # Create the orchestrator agent instructions (still run LLM to summarize) + instructions = f"""You are the Financial Planner Orchestrator. Your job is to coordinate portfolio analysis by calling specialized agents. + +Portfolio Summary: +- Total positions: {portfolio_summary['num_positions']} +- Number of accounts: {portfolio_summary['num_accounts']} +- Years until retirement: {portfolio_summary['years_until_retirement']} +- Total portfolio value: ${portfolio_summary['total_value']:,.2f} + +Your available tools: +1. invoke_reporter_agent: Generate comprehensive portfolio analysis narrative +2. invoke_charter_agent: Create portfolio visualization charts +3. invoke_retirement_agent: Calculate retirement projections and scenarios +4. invoke_tagger_agent: Classify financial instruments (usually done automatically) + +Orchestration Steps: +1. Always call invoke_reporter_agent first if there are positions > 0 +2. Call invoke_charter_agent if there are positions >= 2 (charts need multiple data points) +3. Call invoke_retirement_agent if retirement planning is needed (years_until_retirement > 0) +4. Coordinate the analysis and provide a final summary + +Call each agent with the job_id: {job_id} + +Begin the orchestration process now.""" + + # Create agent + agent = Agent( + model=model, + system_prompt=instructions, + tools=[ + invoke_reporter_agent, + invoke_charter_agent, + invoke_retirement_agent, + invoke_tagger_agent, + ], + ) + + # Create task and run (optional summarization) + task = f"Begin comprehensive portfolio analysis orchestration for job {job_id}" + print(f"Planner: Starting agent orchestration run...") + result = agent(task) + print(f"Planner: Agent orchestration run completed") + + # Extract the text content from the AgentResult + response = result.text if hasattr(result, 'text') else str(result) + print( + "Planner: Final orchestrator response (truncated): " + f"'{(response or '')[:300]}{'...' if response and len(response)>300 else ''}'" + ) + + # Mark job as completed after all agents finish + db.jobs.update_status(job_id, "completed") + print(f"Planner: Job {job_id} completed successfully") + + return response + + except Exception as e: + print(f"Error in orchestration: {e}") + + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + print(f"❌ MaxTokensReachedException caught for job {job_id}: {e}") + if 'db' in locals(): + db.jobs.update_status(job_id, 'max_tokens_exceeded', error_message=f'Agent reached max tokens limit: {str(e)}') + add_job_progress(db, job_id, f"Analysis stopped: Agent reached maximum token limit. This happens when the portfolio is very large or complex.", "planner") + + # Return a graceful response instead of failing completely + return f"Analysis partially completed but was stopped due to reaching maximum token limit. Please try running a more focused analysis or contact support for assistance with large portfolios." + else: + if 'db' in locals(): + db.jobs.update_status(job_id, 'failed', error_message=str(e)) + raise + +print(f"✅ DEBUG: create_agent_and_run function defined") + +print(f"🔍 DEBUG: About to instantiate BedrockAgentCoreApp()") + +app = BedrockAgentCoreApp() + +print(f"🔍 DEBUG: About to define @app.entrypoint function") + +def create_basic_agent() -> Agent: + """Create a basic agent with simple functionality""" + system_prompt = """You are a helpful assistant. Answer questions clearly and concisely.""" + + return Agent( + system_prompt=system_prompt, + name="BasicAgent" + ) + + +# @app.entrypoint +async def invokeme(payload=None): + """Main entrypoint for the agent""" + try: + # Get the query from payload + query = payload.get("prompt", "Hello, how are you?") if payload else "Hello, how are you?" + + # Create and use the agent + agent = create_basic_agent() + response = agent(query) + + return { + "status": "success", + "response": response.message['content'][0]['text'] + } + + except Exception as e: + return { + "status": "error", + "error": str(e) + } + +@app.entrypoint +async def planner_agent(payload): + """Main entry point for the planner orchestrator agent.""" + try: + print(f"🚀 DEBUG: planner_agent entry point called") + print("new") + # Parse the payload + job_id = payload.get("job_id") + if not job_id: + print(f"❌ DEBUG: No job_id in payload") + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'job_id is required'}) + } + + print(f"✅ DEBUG: Found job_id: {job_id}") + print(f"🔄 About to call create_agent_and_run for job: {job_id}") + + print(f"🔍 DEBUG: About to await create_agent_and_run") + # Process the orchestration in a single async context + + + result = create_agent_and_run(job_id) + + + print(f"✅ DEBUG: create_agent_and_run completed successfully") + + final_result = { + 'statusCode': 200, + 'body': json.dumps({ + 'success': True, + 'message': f'Orchestration completed for job {job_id}', + 'final_output': result + }) + } + + print(f"🎉 DEBUG: Returning success result") + return final_result + + except Exception as e: + print(f"❌ DEBUG: Exception in planner_agent: {e}") + + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + return { + 'statusCode': 200, # Return success with explanation + 'body': json.dumps({ + 'success': False, + 'max_tokens_exceeded': True, + 'message': f'Analysis stopped due to maximum token limit reached for job {payload.get("job_id", "unknown")}', + 'error': str(e), + 'recommendation': 'Try reducing the complexity of your portfolio or contact support for assistance with large portfolios.' + }) + } + else: + return { + 'statusCode': 500, + 'body': json.dumps({'error': str(e)}) + } + +print(f"🔍 DEBUG: About to check if __name__ == '__main__'") + +if __name__ == "__main__": + print(f"✅ DEBUG: Agent module loaded successfully, ready for AgentCore invocation") + app.run() + print(f"🔍 app.run() completed ") diff --git a/backend/agent_planner/check_db.py b/backend/agent_planner/check_db.py new file mode 100644 index 00000000..775a8614 --- /dev/null +++ b/backend/agent_planner/check_db.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Check database for payload data after test +""" + +import os +from dotenv import load_dotenv + +load_dotenv(override=True) + +# Add database path +import sys +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'database')) + +from src import Database + +def check_latest_job(): + """Check the latest job in the database for payload data""" + db = Database() + + # Get the most recent job + jobs = db.jobs.find_by_user("test_user_001", limit=1) + if not jobs: + print("No jobs found for test_user_001") + return + + job = jobs[0] + job_id = job['id'] + + print(f"Job ID: {job_id}") + print(f"Status: {job.get('status', 'N/A')}") + print(f"Created: {job.get('created_at', 'N/A')}") + print() + + # Check for payloads + print("Payload Status:") + print(f"- report_payload: {'✅ Present' if job.get('report_payload') else '❌ Missing'}") + print(f"- charts_payload: {'✅ Present' if job.get('charts_payload') else '❌ Missing'}") + print(f"- retirement_payload: {'✅ Present' if job.get('retirement_payload') else '❌ Missing'}") + print() + + # Show payload contents (truncated) + for payload_type in ['report_payload', 'charts_payload', 'retirement_payload']: + payload = job.get(payload_type) + if payload: + print(f"{payload_type}:") + if isinstance(payload, dict): + for key, value in payload.items(): + if isinstance(value, str) and len(value) > 100: + print(f" {key}: {value[:100]}...") + else: + print(f" {key}: {value}") + else: + print(f" {str(payload)[:200]}...") + print() + +if __name__ == "__main__": + check_latest_job() \ No newline at end of file diff --git a/backend/agent_planner/requirements.txt b/backend/agent_planner/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_planner/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_planner/src/__init__.py b/backend/agent_planner/src/__init__.py new file mode 100644 index 00000000..5bc75e95 --- /dev/null +++ b/backend/agent_planner/src/__init__.py @@ -0,0 +1,51 @@ +""" +Database package for Alex Financial Planner +Provides database models, schemas, and Data API client +""" + +from .client import DataAPIClient +from .models import Database +from .schemas import ( + # Types + RegionType, + AssetClassType, + SectorType, + InstrumentType, + JobType, + JobStatus, + AccountType, + + # Create schemas (for inputs) + InstrumentCreate, + UserCreate, + AccountCreate, + PositionCreate, + JobCreate, + JobUpdate, + + # Response schemas (for outputs) + InstrumentResponse, + PortfolioAnalysis, + RebalanceRecommendation, +) + +__all__ = [ + 'Database', + 'DataAPIClient', + 'InstrumentCreate', + 'UserCreate', + 'AccountCreate', + 'PositionCreate', + 'JobCreate', + 'JobUpdate', + 'InstrumentResponse', + 'PortfolioAnalysis', + 'RebalanceRecommendation', + 'RegionType', + 'AssetClassType', + 'SectorType', + 'InstrumentType', + 'JobType', + 'JobStatus', + 'AccountType', +] \ No newline at end of file diff --git a/backend/agent_planner/src/client.py b/backend/agent_planner/src/client.py new file mode 100644 index 00000000..f91994e9 --- /dev/null +++ b/backend/agent_planner/src/client.py @@ -0,0 +1,310 @@ +""" +Aurora Data API Client Wrapper +Provides a simple interface for database operations +""" + +import boto3 +import json +import os +from typing import List, Dict, Any, Optional, Tuple +from datetime import date, datetime +from decimal import Decimal +from botocore.exceptions import ClientError +import logging + +# Try to load .env file if it exists +try: + from dotenv import load_dotenv + + load_dotenv(override=True) +except ImportError: + pass # dotenv not installed, continue without it + +logger = logging.getLogger(__name__) + + +class DataAPIClient: + """Wrapper for AWS RDS Data API to simplify database operations""" + + def __init__( + self, + cluster_arn: str = None, + secret_arn: str = None, + database: str = None, + region: str = None, + ): + """ + Initialize Data API client + + Args: + cluster_arn: Aurora cluster ARN (or from env AURORA_CLUSTER_ARN) + secret_arn: Secrets Manager ARN (or from env AURORA_SECRET_ARN) + database: Database name (or from env AURORA_DATABASE) + region: AWS region (or from env AWS_REGION) + """ + self.cluster_arn = cluster_arn or os.environ.get("AURORA_CLUSTER_ARN") + self.secret_arn = secret_arn or os.environ.get("AURORA_SECRET_ARN") + self.database = database or os.environ.get("AURORA_DATABASE", "alex") + + if not self.cluster_arn or not self.secret_arn: + raise ValueError( + "Missing required Aurora configuration. " + "Set AURORA_CLUSTER_ARN and AURORA_SECRET_ARN environment variables." + ) + + self.region = os.environ.get("DEFAULT_AWS_REGION", "us-east-1") + self.client = boto3.client("rds-data", region_name=self.region) + + def execute(self, sql: str, parameters: List[Dict] = None) -> Dict: + """ + Execute a SQL statement + + Args: + sql: SQL statement to execute + parameters: Optional list of parameters for prepared statement + + Returns: + Response from Data API + """ + try: + kwargs = { + "resourceArn": self.cluster_arn, + "secretArn": self.secret_arn, + "database": self.database, + "sql": sql, + "includeResultMetadata": True, # Include column names + } + + if parameters: + kwargs["parameters"] = parameters + + response = self.client.execute_statement(**kwargs) + return response + + except ClientError as e: + logger.error(f"Database error: {e}") + raise + + def query(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """ + Execute a SELECT query and return results as list of dicts + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + List of dictionaries with column names as keys + """ + response = self.execute(sql, parameters) + + if "records" not in response: + return [] + + # Extract column names + columns = [col["name"] for col in response.get("columnMetadata", [])] + + # Convert records to dictionaries + results = [] + for record in response["records"]: + row = {} + for i, col in enumerate(columns): + value = self._extract_value(record[i]) + row[col] = value + results.append(row) + + return results + + def query_one(self, sql: str, parameters: List[Dict] = None) -> Optional[Dict]: + """ + Execute a SELECT query and return first result + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + Dictionary with column names as keys, or None if no results + """ + results = self.query(sql, parameters) + return results[0] if results else None + + def insert(self, table: str, data: Dict, returning: str = None) -> str: + """ + Insert a record into a table + + Args: + table: Table name + data: Dictionary of column names and values + returning: Column to return (e.g., 'id', 'clerk_user_id') + + Returns: + Value of returning column if specified + """ + columns = list(data.keys()) + placeholders = [] + + # Check if columns need type casting + for col in columns: + if isinstance(data[col], (dict, list)): + placeholders.append(f":{col}::jsonb") + elif isinstance(data[col], Decimal): + placeholders.append(f":{col}::numeric") + elif isinstance(data[col], date) and not isinstance(data[col], datetime): + placeholders.append(f":{col}::date") + elif isinstance(data[col], datetime): + placeholders.append(f":{col}::timestamp") + else: + placeholders.append(f":{col}") + + sql = f""" + INSERT INTO {table} ({", ".join(columns)}) + VALUES ({", ".join(placeholders)}) + """ + + # Add RETURNING clause if specified + if returning: + sql += f" RETURNING {returning}" + + parameters = self._build_parameters(data) + response = self.execute(sql, parameters) + + # Return value if RETURNING was used + if returning and response.get("records"): + return self._extract_value(response["records"][0][0]) + return None + + def update(self, table: str, data: Dict, where: str, where_params: Dict = None) -> int: + """ + Update records in a table + + Args: + table: Table name + data: Dictionary of columns to update + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of affected rows + """ + # Build SET clause with type casting where needed + set_parts = [] + for col, val in data.items(): + if isinstance(val, (dict, list)): + set_parts.append(f"{col} = :{col}::jsonb") + elif isinstance(val, Decimal): + set_parts.append(f"{col} = :{col}::numeric") + elif isinstance(val, date) and not isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::date") + elif isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::timestamp") + else: + set_parts.append(f"{col} = :{col}") + + set_clause = ", ".join(set_parts) + + sql = f""" + UPDATE {table} + SET {set_clause} + WHERE {where} + """ + + # Combine data and where parameters + all_params = {**data, **(where_params or {})} + parameters = self._build_parameters(all_params) + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def delete(self, table: str, where: str, where_params: Dict = None) -> int: + """ + Delete records from a table + + Args: + table: Table name + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of deleted rows + """ + sql = f"DELETE FROM {table} WHERE {where}" + parameters = self._build_parameters(where_params) if where_params else None + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def begin_transaction(self) -> str: + """Begin a database transaction""" + response = self.client.begin_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, database=self.database + ) + return response["transactionId"] + + def commit_transaction(self, transaction_id: str): + """Commit a database transaction""" + self.client.commit_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def rollback_transaction(self, transaction_id: str): + """Rollback a database transaction""" + self.client.rollback_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def _build_parameters(self, data: Dict) -> List[Dict]: + """Convert dictionary to Data API parameter format""" + if not data: + return [] + + parameters = [] + for key, value in data.items(): + param = {"name": key} + + if value is None: + param["value"] = {"isNull": True} + elif isinstance(value, bool): + param["value"] = {"booleanValue": value} + elif isinstance(value, int): + param["value"] = {"longValue": value} + elif isinstance(value, float): + param["value"] = {"doubleValue": value} + elif isinstance(value, Decimal): + param["value"] = {"stringValue": str(value)} + elif isinstance(value, (date, datetime)): + param["value"] = {"stringValue": value.isoformat()} + elif isinstance(value, dict): + param["value"] = {"stringValue": json.dumps(value)} + elif isinstance(value, list): + param["value"] = {"stringValue": json.dumps(value)} + else: + param["value"] = {"stringValue": str(value)} + + parameters.append(param) + + return parameters + + def _extract_value(self, field: Dict) -> Any: + """Extract value from Data API field response""" + if field.get("isNull"): + return None + elif "booleanValue" in field: + return field["booleanValue"] + elif "longValue" in field: + return field["longValue"] + elif "doubleValue" in field: + return field["doubleValue"] + elif "stringValue" in field: + value = field["stringValue"] + # Try to parse JSON if it looks like JSON + if value and value[0] in ["{", "["]: + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + elif "blobValue" in field: + return field["blobValue"] + else: + return None diff --git a/backend/agent_planner/src/models.py b/backend/agent_planner/src/models.py new file mode 100644 index 00000000..903e3594 --- /dev/null +++ b/backend/agent_planner/src/models.py @@ -0,0 +1,320 @@ +""" +Database models and query builders +""" + +from typing import Dict, List, Optional, Any +from datetime import datetime, date +from decimal import Decimal +from .client import DataAPIClient +from .schemas import ( + InstrumentCreate, UserCreate, AccountCreate, + PositionCreate, JobCreate, JobUpdate +) + + +class BaseModel: + """Base class for database models""" + + table_name = None + + def __init__(self, db: DataAPIClient): + self.db = db + if not self.table_name: + raise ValueError("table_name must be defined") + + def find_by_id(self, id: Any) -> Optional[Dict]: + """Find a record by ID""" + sql = f"SELECT * FROM {self.table_name} WHERE id = :id::uuid" + return self.db.query_one(sql, [{'name': 'id', 'value': {'stringValue': str(id)}}]) + + def find_all(self, limit: int = 100, offset: int = 0) -> List[Dict]: + """Find all records with pagination""" + sql = f"SELECT * FROM {self.table_name} LIMIT :limit OFFSET :offset" + params = [ + {'name': 'limit', 'value': {'longValue': limit}}, + {'name': 'offset', 'value': {'longValue': offset}} + ] + return self.db.query(sql, params) + + def create(self, data: Dict, returning: str = 'id') -> str: + """Create a new record""" + return self.db.insert(self.table_name, data, returning=returning) + + def update(self, id: Any, data: Dict) -> int: + """Update a record by ID""" + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': str(id)}) + + def delete(self, id: Any) -> int: + """Delete a record by ID""" + return self.db.delete(self.table_name, "id = :id::uuid", {'id': str(id)}) + + +class Users(BaseModel): + """Users table operations""" + table_name = 'users' + + def find_by_clerk_id(self, clerk_user_id: str) -> Optional[Dict]: + """Find user by Clerk ID""" + sql = f"SELECT * FROM {self.table_name} WHERE clerk_user_id = :clerk_id" + params = [{'name': 'clerk_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query_one(sql, params) + + def create_user(self, clerk_user_id: str, display_name: str = None, + years_until_retirement: int = None, + target_retirement_income: Decimal = None) -> str: + """Create a new user""" + data = { + 'clerk_user_id': clerk_user_id, + 'display_name': display_name, + 'years_until_retirement': years_until_retirement, + 'target_retirement_income': target_retirement_income + } + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + return self.db.insert(self.table_name, data, returning='clerk_user_id') + + +class Instruments(BaseModel): + """Instruments table operations""" + table_name = 'instruments' + + def find_all(self, limit: int = None, offset: int = 0) -> List[Dict]: + """Find all instruments - no limit by default for autocomplete""" + sql = f"SELECT * FROM {self.table_name} ORDER BY symbol" + return self.db.query(sql, []) + + def find_by_symbol(self, symbol: str) -> Optional[Dict]: + """Find instrument by symbol""" + sql = f"SELECT * FROM {self.table_name} WHERE symbol = :symbol" + params = [{'name': 'symbol', 'value': {'stringValue': symbol}}] + return self.db.query_one(sql, params) + + def create_instrument(self, instrument: InstrumentCreate) -> str: + """Create a new instrument with validation""" + # Validate using Pydantic + validated = instrument.model_dump() + + # Convert allocations to JSON strings for storage + data = { + 'symbol': validated['symbol'], + 'name': validated['name'], + 'instrument_type': validated['instrument_type'], + 'allocation_regions': validated['allocation_regions'], + 'allocation_sectors': validated['allocation_sectors'], + 'allocation_asset_class': validated['allocation_asset_class'] + } + + return self.db.insert(self.table_name, data, returning='symbol') + + def find_by_type(self, instrument_type: str) -> List[Dict]: + """Find all instruments of a specific type""" + sql = f"SELECT * FROM {self.table_name} WHERE instrument_type = :type ORDER BY symbol" + params = [{'name': 'type', 'value': {'stringValue': instrument_type}}] + return self.db.query(sql, params) + + def search(self, query: str) -> List[Dict]: + """Search instruments by symbol or name""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE LOWER(symbol) LIKE LOWER(:query) + OR LOWER(name) LIKE LOWER(:query) + ORDER BY symbol + LIMIT 20 + """ + params = [{'name': 'query', 'value': {'stringValue': f'%{query}%'}}] + return self.db.query(sql, params) + + +class Accounts(BaseModel): + """Accounts table operations""" + table_name = 'accounts' + + def find_by_user(self, clerk_user_id: str) -> List[Dict]: + """Find all accounts for a user""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + """ + params = [{'name': 'user_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query(sql, params) + + def create_account(self, clerk_user_id: str, account_name: str, + account_purpose: str = None, cash_balance: Decimal = Decimal('0'), + cash_interest: Decimal = Decimal('0')) -> str: + """Create a new account""" + data = { + 'clerk_user_id': clerk_user_id, + 'account_name': account_name, + 'account_purpose': account_purpose, + 'cash_balance': cash_balance, + 'cash_interest': cash_interest + } + return self.db.insert(self.table_name, data, returning='id') + + +class Positions(BaseModel): + """Positions table operations""" + table_name = 'positions' + + def find_by_account(self, account_id: str) -> List[Dict]: + """Find all positions in an account""" + sql = f""" + SELECT p.*, i.name as instrument_name, i.instrument_type, i.current_price + FROM {self.table_name} p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + ORDER BY p.symbol + """ + params = [{'name': 'account_id', 'value': {'stringValue': account_id}}] + return self.db.query(sql, params) + + def get_portfolio_value(self, account_id: str) -> Dict: + """Calculate total portfolio value using current prices from instruments table""" + sql = """ + SELECT + COUNT(DISTINCT p.symbol) as num_positions, + SUM(p.quantity * i.current_price) as total_value, + SUM(p.quantity) as total_shares + FROM positions p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}} + ] + result = self.db.query_one(sql, params) + if result: + return { + 'num_positions': result.get('num_positions', 0), + 'total_value': float(result.get('total_value', 0)) if result.get('total_value') else 0, + 'total_shares': float(result.get('total_shares', 0)) if result.get('total_shares') else 0 + } + return {'num_positions': 0, 'total_value': 0, 'total_shares': 0} + + def add_position(self, account_id: str, symbol: str, quantity: Decimal) -> str: + """Add or update a position""" + # Use UPSERT to handle existing positions + sql = """ + INSERT INTO positions (account_id, symbol, quantity, as_of_date) + VALUES (:account_id::uuid, :symbol, :quantity::numeric, :as_of_date::date) + ON CONFLICT (account_id, symbol) + DO UPDATE SET + quantity = EXCLUDED.quantity, + as_of_date = EXCLUDED.as_of_date, + updated_at = NOW() + RETURNING id + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}}, + {'name': 'symbol', 'value': {'stringValue': symbol}}, + {'name': 'quantity', 'value': {'stringValue': str(quantity)}}, + {'name': 'as_of_date', 'value': {'stringValue': date.today().isoformat()}} + ] + response = self.db.execute(sql, params) + if response.get('records'): + return response['records'][0][0].get('stringValue') + return None + + +class Jobs(BaseModel): + """Jobs table operations""" + table_name = 'jobs' + + def create_job(self, clerk_user_id: str, job_type: str, + request_payload: Dict = None) -> str: + """Create a new job""" + data = { + 'clerk_user_id': clerk_user_id, + 'job_type': job_type, + 'status': 'pending', + 'request_payload': request_payload + } + return self.db.insert(self.table_name, data, returning='id') + + def update_status(self, job_id: str, status: str, error_message: str = None) -> int: + """Update job status""" + data = {'status': status} + + if status == 'running': + data['started_at'] = datetime.utcnow() + elif status in ['completed', 'failed', 'max_tokens_exceeded']: + data['completed_at'] = datetime.utcnow() + + if error_message: + data['error_message'] = error_message + + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_report(self, job_id: str, report_payload: Dict) -> int: + """Update job with Reporter agent's analysis""" + data = {'report_payload': report_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_charts(self, job_id: str, charts_payload: Dict) -> int: + """Update job with Charter agent's visualization data""" + data = {'charts_payload': charts_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_retirement(self, job_id: str, retirement_payload: Dict) -> int: + """Update job with Retirement agent's projections""" + data = {'retirement_payload': retirement_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_summary(self, job_id: str, summary_payload: Dict) -> int: + """Update job with Planner's final summary""" + data = {'summary_payload': summary_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def find_by_user(self, clerk_user_id: str, status: str = None, + limit: int = 20) -> List[Dict]: + """Find jobs for a user""" + if status: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id AND status = :status + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'status', 'value': {'stringValue': status}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + else: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + + return self.db.query(sql, params) + + +class Database: + """Main database interface providing access to all models""" + + def __init__(self, cluster_arn: str = None, secret_arn: str = None, + database: str = None, region: str = None): + """Initialize database with all model classes""" + self.client = DataAPIClient(cluster_arn, secret_arn, database, region) + + # Initialize all models + self.users = Users(self.client) + self.instruments = Instruments(self.client) + self.accounts = Accounts(self.client) + self.positions = Positions(self.client) + self.jobs = Jobs(self.client) + + def execute_raw(self, sql: str, parameters: List[Dict] = None) -> Dict: + """Execute raw SQL for complex queries""" + return self.client.execute(sql, parameters) + + def query_raw(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """Execute raw SELECT query""" + return self.client.query(sql, parameters) \ No newline at end of file diff --git a/backend/agent_planner/src/schemas.py b/backend/agent_planner/src/schemas.py new file mode 100644 index 00000000..44f16514 --- /dev/null +++ b/backend/agent_planner/src/schemas.py @@ -0,0 +1,284 @@ +""" +Pydantic schemas for data validation and LLM tool interfaces +These models serve as both database validation and LLM structured output schemas +""" + +from typing import Dict, Literal, Optional, List +from pydantic import BaseModel, Field, field_validator +from decimal import Decimal +from datetime import date, datetime + + +# Define allowed values as Literals for LLM compatibility +RegionType = Literal[ + "north_america", + "europe", + "asia", + "latin_america", + "africa", + "middle_east", + "oceania", + "global", + "international", # For mixed non-US +] + +AssetClassType = Literal[ + "equity", "fixed_income", "real_estate", "commodities", "cash", "alternatives" +] + +SectorType = Literal[ + "technology", + "healthcare", + "financials", + "consumer_discretionary", + "consumer_staples", + "industrials", + "energy", + "materials", + "utilities", + "real_estate", + "communication", + "treasury", + "corporate", + "mortgage", + "government_related", + "commodities", + "diversified", + "other", +] + +InstrumentType = Literal["etf", "mutual_fund", "stock", "bond", "bond_fund", "commodity", "reit"] + +JobType = Literal[ + "portfolio_analysis", + "rebalance_recommendation", + "retirement_projection", + "risk_assessment", + "tax_optimization", + "instrument_research", +] + +JobStatus = Literal["pending", "running", "completed", "failed", "max_tokens_exceeded"] + +AccountType = Literal[ + "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" +] + + +class AllocationDict(BaseModel): + """Base class for allocation dictionaries ensuring they sum to 100""" + + @field_validator("*", mode="after") + def validate_sum(cls, v, info): + """Ensure allocation percentages sum to 100""" + if isinstance(v, dict): + total = sum(v.values()) + if abs(total - 100) > 3: # Allow small floating point errors + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class RegionAllocation(BaseModel): + """Geographic allocation of an instrument""" + + allocations: Dict[RegionType, float] = Field( + description="Percentage allocation by geographic region. Must sum to 100.", + example={"north_america": 60, "europe": 25, "asia": 15}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Region allocations must sum to 100, got {total}") + return v + + +class AssetClassAllocation(BaseModel): + """Asset class allocation of an instrument""" + + allocations: Dict[AssetClassType, float] = Field( + description="Percentage allocation by asset class. Must sum to 100.", + example={"equity": 80, "fixed_income": 20}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Asset class allocations must sum to 100, got {total}") + return v + + +class SectorAllocation(BaseModel): + """Sector allocation of an instrument""" + + allocations: Dict[SectorType, float] = Field( + description="Percentage allocation by market sector. Must sum to 100.", + example={"technology": 30, "healthcare": 25, "financials": 20, "other": 25}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Sector allocations must sum to 100, got {total}") + return v + + +class InstrumentCreate(BaseModel): + """Schema for creating a new instrument - suitable for LLM tool input""" + + symbol: str = Field( + description="The ticker symbol of the instrument (e.g., 'SPY', 'BND')", + min_length=1, + max_length=20, + ) + name: str = Field(description="Full name of the instrument", min_length=1, max_length=255) + instrument_type: InstrumentType = Field(description="The type of financial instrument") + current_price: Optional[Decimal] = Field( + None, + description="Current price of the instrument for portfolio calculations", + ge=0, + le=999999, + ) + allocation_regions: Dict[RegionType, float] = Field( + description="Geographic allocation percentages. Must sum to 100.", + example={"north_america": 100}, + ) + allocation_sectors: Dict[SectorType, float] = Field( + description="Sector allocation percentages. Must sum to 100.", + example={"technology": 40, "healthcare": 30, "financials": 30}, + ) + allocation_asset_class: Dict[AssetClassType, float] = Field( + description="Asset class allocation percentages. Must sum to 100.", example={"equity": 100} + ) + + @field_validator("allocation_regions", "allocation_sectors", "allocation_asset_class") + def validate_allocations(cls, v): + """Ensure all allocations sum to 100""" + if not v: + raise ValueError("Allocation cannot be empty") + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class InstrumentResponse(InstrumentCreate): + """Schema for instrument responses from database""" + + created_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + """Schema for creating a user - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="Unique identifier from Clerk authentication system") + display_name: Optional[str] = Field(None, description="User's display name", max_length=255) + years_until_retirement: Optional[int] = Field( + None, description="Number of years until the user plans to retire", ge=0, le=100 + ) + target_retirement_income: Optional[Decimal] = Field( + None, description="Annual income goal in retirement (in dollars)", ge=0, decimal_places=2 + ) + asset_class_targets: Optional[Dict[AssetClassType, float]] = Field( + default={"equity": 70, "fixed_income": 30}, + description="Target allocation percentages for rebalancing. Must sum to 100.", + ) + region_targets: Optional[Dict[RegionType, float]] = Field( + default={"north_america": 50, "international": 50}, + description="Target geographic allocation for rebalancing. Must sum to 100.", + ) + + +class AccountCreate(BaseModel): + """Schema for creating an account - suitable for LLM tool input""" + + account_name: str = Field( + description="Name of the account (e.g., '401k', 'Roth IRA')", min_length=1, max_length=255 + ) + account_purpose: Optional[str] = Field(None, description="Purpose or goal of this account") + cash_balance: Decimal = Field( + default=Decimal("0"), + description="Uninvested cash balance in the account", + ge=0, + decimal_places=2, + ) + cash_interest: Decimal = Field( + default=Decimal("0"), + description="Annual interest rate on cash (e.g., 0.045 for 4.5%)", + ge=0, + le=1, + decimal_places=4, + ) + + +class PositionCreate(BaseModel): + """Schema for creating a position - suitable for LLM tool input""" + + account_id: str = Field(description="UUID of the account holding this position") + symbol: str = Field(description="Ticker symbol of the instrument", min_length=1, max_length=20) + quantity: Decimal = Field( + description="Number of shares (supports fractional shares)", gt=0, decimal_places=8 + ) + as_of_date: Optional[date] = Field( + default_factory=date.today, description="Date of this position snapshot" + ) + + +class JobCreate(BaseModel): + """Schema for creating a job - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="User requesting this job") + job_type: JobType = Field(description="Type of analysis or operation to perform") + request_payload: Optional[Dict] = Field(None, description="Input parameters for the job") + + +class JobUpdate(BaseModel): + """Schema for updating job status - suitable for LLM tool output""" + + status: JobStatus = Field(description="Current status of the job") + result_payload: Optional[Dict] = Field(None, description="Results of the completed job") + error_message: Optional[str] = Field(None, description="Error details if job failed") + + +class PortfolioAnalysis(BaseModel): + """Schema for portfolio analysis results - LLM structured output""" + + total_value: Decimal = Field(description="Total portfolio value in dollars", decimal_places=2) + asset_allocation: Dict[AssetClassType, float] = Field( + description="Current asset class allocation percentages" + ) + region_allocation: Dict[RegionType, float] = Field( + description="Current geographic allocation percentages" + ) + sector_allocation: Dict[SectorType, float] = Field( + description="Current sector allocation percentages" + ) + risk_score: int = Field( + description="Risk score from 1 (conservative) to 10 (aggressive)", ge=1, le=10 + ) + recommendations: List[str] = Field( + description="List of actionable recommendations for the portfolio" + ) + + +class RebalanceRecommendation(BaseModel): + """Schema for rebalancing recommendations - LLM structured output""" + + current_allocation: Dict[str, float] = Field( + description="Current allocation by instrument symbol" + ) + target_allocation: Dict[str, float] = Field( + description="Recommended target allocation by symbol" + ) + trades: List[Dict] = Field( + description="List of trades needed to rebalance", + example=[ + {"symbol": "SPY", "action": "sell", "quantity": 10}, + {"symbol": "BND", "action": "buy", "quantity": 50}, + ], + ) + rationale: str = Field(description="Explanation of why these changes are recommended") diff --git a/backend/agent_planner/test_arns.py b/backend/agent_planner/test_arns.py new file mode 100644 index 00000000..cff32a27 --- /dev/null +++ b/backend/agent_planner/test_arns.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +""" +Test agent ARN resolution +""" + +import os +from dotenv import load_dotenv + +load_dotenv(override=True) + +# Load SSM environment variables +from utils import load_env_from_ssm +load_env_from_ssm() + +from tools import get_agent_arn + +def test_agent_arns(): + """Test if we can resolve agent ARNs""" + agents = ['reporter', 'charter', 'retirement', 'tagger'] + + for agent_name in agents: + arn = get_agent_arn(agent_name) + if arn: + print(f"✅ {agent_name}: {arn}") + else: + print(f"❌ {agent_name}: No ARN found") + +if __name__ == "__main__": + test_agent_arns() \ No newline at end of file diff --git a/backend/agent_planner/test_full.py b/backend/agent_planner/test_full.py new file mode 100644 index 00000000..6d0ffc93 --- /dev/null +++ b/backend/agent_planner/test_full.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Run a full end-to-end test of the Agent Planner orchestration. +This creates a test job and calls the agent directly with Bedrock. + +Usage: + cd backend/agent_planner + uv run test_full.py +""" + +import os +import json +import boto3 +import time +import logging +from datetime import datetime, timezone +from dotenv import load_dotenv + +# Load environment +load_dotenv(override=True) + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Import database +from src import Database +from src.schemas import JobCreate + +db = Database() + + +def create_test_job(): + """Create a test job for orchestration.""" + + # Create test user + test_user_id = "test_user_full_agent_planner" + + try: + # Try to create user + user_data = { + "clerk_user_id": test_user_id, + "display_name": "Test User Agent Planner Full", + "years_until_retirement": 25, + "target_retirement_income": 75000 + } + db.users.create_user(**user_data) + print(f"✓ Created test user: {test_user_id}") + except Exception as e: + print(f"ℹ️ User might already exist: {e}") + + # Create test account and positions + try: + # Create account + account_data = { + "clerk_user_id": test_user_id, + "account_name": "Test Investment Account", + "account_type": "investment", + "cash_balance": 10000.0 + } + account_id = db.accounts.create(**account_data) + print(f"✓ Created test account: {account_id}") + + # Create test positions + test_positions = [ + {"symbol": "SPY", "quantity": 50.0}, + {"symbol": "BND", "quantity": 100.0}, + {"symbol": "VTI", "quantity": 25.0}, + {"symbol": "VXUS", "quantity": 30.0}, + {"symbol": "QQQ", "quantity": 15.0} + ] + + for pos in test_positions: + db.positions.create(account_id=account_id, **pos) + print(f"✓ Created position: {pos['symbol']}") + + except Exception as e: + print(f"ℹ️ Test data might already exist: {e}") + + # Create job + job_create = JobCreate( + clerk_user_id=test_user_id, + job_type="portfolio_analysis", + request_payload={"analysis_type": "comprehensive", "test": True} + ) + + job_id = db.jobs.create(job_create.model_dump()) + print(f"✓ Created test job: {job_id}") + + return job_id, test_user_id + + +def main(): + """Run the full test.""" + + print("🚀 Agent Planner Full Test") + print("=" * 70) + + # Create test job + print("\n📋 Setting up test data...") + try: + job_id, test_user_id = create_test_job() + except Exception as e: + print(f"❌ Failed to create test job: {e}") + return 1 + + # Test the agent directly + print(f"\n🤖 Testing Agent Planner directly...") + print(f"Job ID: {job_id}") + print("-" * 50) + + try: + # Import and test the agent + from agent import planner_agent + + test_payload = { + "job_id": job_id + } + + start_time = time.time() + result = planner_agent(test_payload) + elapsed_time = time.time() - start_time + + print(f"⏱️ Agent execution time: {elapsed_time:.2f} seconds") + print(f"📤 Status Code: {result['statusCode']}") + + if result['statusCode'] == 200: + body = json.loads(result['body']) + print(f"✅ Success: {body.get('success', False)}") + print(f"📝 Message: {body.get('message', 'N/A')}") + + # Show output preview + final_output = body.get('final_output', '') + if final_output: + print(f"\n📊 Output Preview ({len(final_output)} chars):") + print("-" * 50) + preview = final_output[:500] + if len(final_output) > 500: + preview += "..." + print(preview) + + else: + body = json.loads(result['body']) + print(f"❌ Error: {body.get('error', 'Unknown error')}") + return 1 + + except Exception as e: + print(f"❌ Agent test failed: {e}") + import traceback + traceback.print_exc() + return 1 + + # Check job status in database + print(f"\n📋 Checking final job status...") + try: + job = db.jobs.find_by_id(job_id) + print(f"📊 Job Status: {job['status']}") + + if job.get('error_message'): + print(f"⚠️ Error Message: {job['error_message']}") + + # Display any results that were saved + if job.get('report_payload'): + print(f"📝 Report saved: {len(str(job['report_payload']))} chars") + + if job.get('charts_payload'): + print(f"📊 Charts saved: {len(job['charts_payload'])} items") + + if job.get('retirement_payload'): + print(f"🎯 Retirement analysis saved") + + except Exception as e: + print(f"⚠️ Could not check job status: {e}") + + # Clean up + print(f"\n🧹 Cleaning up test data...") + try: + # Delete test job + db.jobs.delete(job_id) + print(f"✓ Deleted test job: {job_id}") + + # Delete test user and related data + db.client.delete("users", "clerk_user_id = :clerk_id", {"clerk_id": test_user_id}) + print(f"✓ Deleted test user: {test_user_id}") + + except Exception as e: + print(f"⚠️ Cleanup failed: {e}") + + print("\n" + "=" * 70) + print("✅ Full Agent Planner test completed!") + print("=" * 70) + + return 0 + + +if __name__ == "__main__": + exit(main()) \ No newline at end of file diff --git a/backend/agent_planner/test_simple.py b/backend/agent_planner/test_simple.py new file mode 100644 index 00000000..51b53cf4 --- /dev/null +++ b/backend/agent_planner/test_simple.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Simple test for Agent Planner orchestrator +""" + +import asyncio +import json +import os +import subprocess +from dotenv import load_dotenv + +load_dotenv(override=True) + +# Mock lambdas for testing +os.environ['MOCK_LAMBDAS'] = 'true' + +from src import Database +from src.schemas import JobCreate + +def setup_test_data(): + """Ensure test data exists and create a test job""" + # Run reset_db with test data to ensure we have a test user and portfolio + print("Ensuring test data exists...") + result = subprocess.run( + ["uv", "run", "reset_db.py", "--with-test-data", "--skip-drop"], + cwd="../database", + capture_output=True, + text=True + ) + if result.returncode != 0: + print(f"Warning: Could not ensure test data: {result.stderr}") + + db = Database() + + # The reset_db script creates test_user_001 + test_user_id = "test_user_001" + + # Check if user exists + user = db.users.find_by_clerk_id(test_user_id) + if not user: + raise ValueError(f"Test user {test_user_id} not found. Please run: cd ../database && uv run reset_db.py --with-test-data") + + # Create test job + job_create = JobCreate( + clerk_user_id=test_user_id, + job_type="portfolio_analysis", + request_payload={"analysis_type": "comprehensive", "test": True} + ) + job_id = db.jobs.create(job_create.model_dump()) + + return job_id + +def test_agent_planner(): + """Test the agent planner orchestrator""" + + # Setup test data + job_id = setup_test_data() + + test_payload = { + "job_id": job_id + } + + print("Testing Agent Planner Orchestrator...") + print(f"Job ID: {job_id}") + print("=" * 60) + + from agent import planner_agent + + # Wait for the async planner_agent to complete + result = asyncio.run(planner_agent(test_payload)) + + print(result) + # print(f"Status Code: {result['statusCode']}") + + # if result['statusCode'] == 200: + # body = json.loads(result['body']) + # print(f"Success: {body.get('success', False)}") + # print(f"Message: {body.get('message', 'N/A')}") + # else: + # body = json.loads(result['body']) + # print(f"Error: {body.get('error', 'Unknown error')}") + + # print("=" * 60) + +if __name__ == "__main__": + test_agent_planner() \ No newline at end of file diff --git a/backend/agent_planner/tools.py b/backend/agent_planner/tools.py new file mode 100644 index 00000000..07587494 --- /dev/null +++ b/backend/agent_planner/tools.py @@ -0,0 +1,96 @@ +""" +Utility tools for the Financial Planner Orchestrator Agent +""" + +import os +import logging +import boto3 +import json + +logger = logging.getLogger(__name__) + +def get_env_var(key: str, default: str = "") -> str: + """Get environment variable at runtime for AgentCore compatibility.""" + return os.environ.get(key, default) + +def get_agent_arn(agent_name: str) -> str: + """Get AgentCore runtime ARN for an agent from SSM Parameter Store. + + Primary path: /agents/{name}_agent_arn (set by terraform/deploy script) + Fallback path: /alex/agents/{name} + """ + region = os.environ.get("DEFAULT_AWS_REGION") or os.environ.get("AWS_REGION") + ssm = boto3.client('ssm', region_name=region) if region else boto3.client('ssm') + paths = [ + f"/agents/{agent_name}_agent_arn", + f"/alex/agents/{agent_name}", + ] + for name in paths: + try: + resp = ssm.get_parameter(Name=name) + value = resp['Parameter']['Value'] + if value: + logger.info(f"Resolved {agent_name} runtime ARN from SSM: {name}") + return value + except Exception: + continue + logger.error(f"Could not resolve runtime ARN for agent '{agent_name}' from SSM. Tried: {paths}") + return "" + +async def invoke_agent_with_boto3(agent_runtime_arn: str, session_id: str, payload: dict) -> str: + """Invoke an AgentCore agent runtime with a JSON payload. + + Uses the bedrock-agentcore InvokeAgentRuntime API which expects: + - agentRuntimeArn: the runtime ARN + - payload: JSON string passed through to the agent's @app.entrypoint + """ + region = os.environ.get("DEFAULT_AWS_REGION") or os.environ.get("AWS_REGION") + client = boto3.client('bedrock-agentcore', region_name=region) if region else boto3.client('bedrock-agentcore') + + try: + # Always include session id for tracing if provided + if session_id and 'session_id' not in payload: + payload = {**payload, 'session_id': session_id} + + resp = client.invoke_agent_runtime( + agentRuntimeArn=agent_runtime_arn, + payload=json.dumps(payload) + ) + + # Handle StreamingBody response properly + # Check for 'response' field first (bedrock-agentcore format), then 'body' field + response_body = None + if isinstance(resp, dict): + if 'response' in resp: + response_body = resp['response'] + elif 'body' in resp: + response_body = resp['body'] + + if response_body: + # Check if body is a StreamingBody (from botocore.response) + if hasattr(response_body, 'read'): + # Read the streaming body + body_content = response_body.read() + if isinstance(body_content, bytes): + body_content = body_content.decode('utf-8') + logger.info(f"AgentCore response body: {body_content}") + return body_content + elif isinstance(response_body, (bytes, bytearray)): + body_content = response_body.decode('utf-8') + logger.info(f"AgentCore response body: {body_content}") + return body_content + elif isinstance(response_body, str): + logger.info(f"AgentCore response body: {response_body}") + return response_body + else: + # Try to JSON serialize other response types + logger.info(f"AgentCore response body type: {type(response_body)}") + return json.dumps(response_body, default=str) + + # If no body field, try to handle the whole response + logger.info(f"AgentCore response type: {type(resp)}, content: {resp}") + return json.dumps(resp, default=str) + + except Exception as e: + logger.error(f"Error invoking agent runtime {agent_runtime_arn}: {e}") + return f"Error invoking agent: {str(e)}" \ No newline at end of file diff --git a/backend/agent_planner/utils.py b/backend/agent_planner/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/agent_planner/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/agent_planner/uv.lock b/backend/agent_planner/uv.lock new file mode 100644 index 00000000..3deee57b --- /dev/null +++ b/backend/agent_planner/uv.lock @@ -0,0 +1,2133 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "agent-planner" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alex-database" }, + { name = "bedrock-agentcore" }, + { name = "bedrock-agentcore-starter-toolkit" }, + { name = "boto3" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "sqlalchemy" }, + { name = "strands-agents" }, + { name = "strands-agents-tools" }, +] + +[package.metadata] +requires-dist = [ + { name = "alex-database", editable = "../database" }, + { name = "bedrock-agentcore", specifier = ">=1.0.3" }, + { name = "bedrock-agentcore-starter-toolkit", specifier = ">=0.1.26" }, + { name = "boto3", specifier = ">=1.40.29" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "strands-agents", specifier = ">=1.13.0" }, + { name = "strands-agents-tools", specifier = ">=0.2.12" }, +] + +[[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/1a/72/d463a10bf29871f6e3f63bcf3c91362dc4d72ed5917a8271f96672c415ad/aiohttp-3.13.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0760bd9a28efe188d77b7c3fe666e6ef74320d0f5b105f2e931c7a7e884c8230", size = 736218, upload-time = "2025-10-17T14:00:03.51Z" }, + { url = "https://files.pythonhosted.org/packages/26/13/f7bccedbe52ea5a6eef1e4ebb686a8d7765319dfd0a5939f4238cb6e79e6/aiohttp-3.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7129a424b441c3fe018a414401bf1b9e1d49492445f5676a3aecf4f74f67fcdb", size = 491251, upload-time = "2025-10-17T14:00:05.756Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7c/7ea51b5aed6cc69c873f62548da8345032aa3416336f2d26869d4d37b4a2/aiohttp-3.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e1cb04ae64a594f6ddf5cbb024aba6b4773895ab6ecbc579d60414f8115e9e26", size = 490394, upload-time = "2025-10-17T14:00:07.504Z" }, + { url = "https://files.pythonhosted.org/packages/31/05/1172cc4af4557f6522efdee6eb2b9f900e1e320a97e25dffd3c5a6af651b/aiohttp-3.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:782d656a641e755decd6bd98d61d2a8ea062fd45fd3ff8d4173605dd0d2b56a1", size = 1737455, upload-time = "2025-10-17T14:00:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/24/3d/ce6e4eca42f797d6b1cd3053cf3b0a22032eef3e4d1e71b9e93c92a3f201/aiohttp-3.13.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f92ad8169767429a6d2237331726c03ccc5f245222f9373aa045510976af2b35", size = 1699176, upload-time = "2025-10-17T14:00:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/25/04/7127ba55653e04da51477372566b16ae786ef854e06222a1c96b4ba6c8ef/aiohttp-3.13.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e778f634ca50ec005eefa2253856921c429581422d887be050f2c1c92e5ce12", size = 1767216, upload-time = "2025-10-17T14:00:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/43bca1e75847e600f40df829a6b2f0f4e1d4c70fb6c4818fdc09a462afd5/aiohttp-3.13.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bc36b41cf4aab5d3b34d22934a696ab83516603d1bc1f3e4ff9930fe7d245e5", size = 1865870, upload-time = "2025-10-17T14:00:15.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/69/b204e5d43384197a614c88c1717c324319f5b4e7d0a1b5118da583028d40/aiohttp-3.13.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3fd4570ea696aee27204dd524f287127ed0966d14d309dc8cc440f474e3e7dbd", size = 1751021, upload-time = "2025-10-17T14:00:18.297Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/845dc6b6fdf378791d720364bf5150f80d22c990f7e3a42331d93b337cc7/aiohttp-3.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7bda795f08b8a620836ebfb0926f7973972a4bf8c74fdf9145e489f88c416811", size = 1561448, upload-time = "2025-10-17T14:00:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/91/d2ab08cd77ed76a49e4106b1cfb60bce2768242dd0c4f9ec0cb01e2cbf94/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:055a51d90e351aae53dcf324d0eafb2abe5b576d3ea1ec03827d920cf81a1c15", size = 1698196, upload-time = "2025-10-17T14:00:22.131Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d1/082f0620dc428ecb8f21c08a191a4694915cd50f14791c74a24d9161cc50/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d4131df864cbcc09bb16d3612a682af0db52f10736e71312574d90f16406a867", size = 1719252, upload-time = "2025-10-17T14:00:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/fc/78/2af2f44491be7b08e43945b72d2b4fd76f0a14ba850ba9e41d28a7ce716a/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:163d3226e043f79bf47c87f8dfc89c496cc7bc9128cb7055ce026e435d551720", size = 1736529, upload-time = "2025-10-17T14:00:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/b0/34/3e919ecdc93edaea8d140138049a0d9126141072e519535e2efa38eb7a02/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a2370986a3b75c1a5f3d6f6d763fc6be4b430226577b0ed16a7c13a75bf43d8f", size = 1553723, upload-time = "2025-10-17T14:00:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/d8003aeda2f67f359b37e70a5a4b53fee336d8e89511ac307ff62aeefcdb/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d7c14de0c7c9f1e6e785ce6cbe0ed817282c2af0012e674f45b4e58c6d4ea030", size = 1763394, upload-time = "2025-10-17T14:00:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7b/1dbe6a39e33af9baaafc3fc016a280663684af47ba9f0e5d44249c1f72ec/aiohttp-3.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb611489cf0db10b99beeb7280bd39e0ef72bc3eb6d8c0f0a16d8a56075d1eb7", size = 1718104, upload-time = "2025-10-17T14:00:33.407Z" }, + { url = "https://files.pythonhosted.org/packages/5c/88/bd1b38687257cce67681b9b0fa0b16437be03383fa1be4d1a45b168bef25/aiohttp-3.13.1-cp312-cp312-win32.whl", hash = "sha256:f90fe0ee75590f7428f7c8b5479389d985d83c949ea10f662ab928a5ed5cf5e6", size = 425303, upload-time = "2025-10-17T14:00:35.829Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/4481f50dd6f27e9e58c19a60cff44029641640237e35d32b04aaee8cf95f/aiohttp-3.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:3461919a9dca272c183055f2aab8e6af0adc810a1b386cce28da11eb00c859d9", size = 452071, upload-time = "2025-10-17T14:00:37.764Z" }, + { 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" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "alex-database" +version = "0.1.0" +source = { editable = "../database" } +dependencies = [ + { name = "boto3" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.40.8" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "autopep8" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycodestyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/30873d2b7b57dee9263e53d142da044c4600a46f2d28374b3e38b023df16/autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", size = 92210, upload-time = "2025-01-14T14:46:18.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, +] + +[[package]] +name = "aws-requests-auth" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/b2/455c0bfcbd772dafd4c9e93c4b713e36790abf9ccbca9b8e661968b29798/aws-requests-auth-0.4.3.tar.gz", hash = "sha256:33593372018b960a31dbbe236f89421678b885c35f0b6a7abfae35bb77e069b2", size = 10096, upload-time = "2020-05-27T23:10:34.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/11/5dc8be418e1d54bed15eaf3a7461797e5ebb9e6a34869ad750561f35fa5b/aws_requests_auth-0.4.3-py2.py3-none-any.whl", hash = "sha256:646bc37d62140ea1c709d20148f5d43197e6bd2d63909eb36fa4bb2345759977", size = 6838, upload-time = "2020-05-27T23:10:33.658Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "bedrock-agentcore" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/57/eee3388b8e6e38c5d667f54053df9718ad1be456ce5885865c8074d726b4/bedrock_agentcore-1.0.3.tar.gz", hash = "sha256:67dcc3a47815d36f368fc3f51636b9ee6a0e0ca8a908868d5bafd4a88efcad93", size = 267907, upload-time = "2025-10-16T18:26:30.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/cb/d6970e331a65ccb9eb6848cd49542161cd6c99ad00d6e5fc3e164d6dc8ca/bedrock_agentcore-1.0.3-py3-none-any.whl", hash = "sha256:6d281bedcec04405c50a108a977ec10d647b10983f05439aa7c7b258fd512c9a", size = 79695, upload-time = "2025-10-16T18:26:28.625Z" }, +] + +[[package]] +name = "bedrock-agentcore-starter-toolkit" +version = "0.1.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autopep8" }, + { name = "bedrock-agentcore" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "openapi-spec-validator" }, + { name = "prance" }, + { name = "prompt-toolkit" }, + { name = "py-openapi-schema-to-json-schema" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "requests" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "starlette" }, + { name = "toml" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/67/4802cc51a125ec6ac84a1432b9f066794ee8b6729f2fb90efe9353a343d8/bedrock_agentcore_starter_toolkit-0.1.26.tar.gz", hash = "sha256:2ca47524029d73910e18115799b3066ebfd0ad9864490f415010cc97ff22fa35", size = 543528, upload-time = "2025-10-17T16:58:35.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/2d/2988955906035f0a6a6eda2d24ac9c229c65122311895772addc1e6434d8/bedrock_agentcore_starter_toolkit-0.1.26-py3-none-any.whl", hash = "sha256:5a6568f1c68779ec901c2ab52dd7e7d20f039dc62c009c3dc85947b1660bea25", size = 200048, upload-time = "2025-10-17T16:58:34.028Z" }, +] + +[[package]] +name = "boto3" +version = "1.40.55" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/a279c054e0c9731172f05b3d118f3ffc9d74806657f84fc0c93c42d1bb5d/boto3-1.40.55.tar.gz", hash = "sha256:27e35b4fa9edd414ce06c1a748bf57cacd8203271847d93fc1053e4a4ec6e1a9", size = 111590, upload-time = "2025-10-17T19:34:56.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/8c/559c6145d857ed953536a83f3a94915bbd5d3d2d406db1abf8bf40be7645/boto3-1.40.55-py3-none-any.whl", hash = "sha256:2e30f5a0d49e107b8a5c0c487891afd300bfa410e1d918bf187ae45ac3839332", size = 139322, upload-time = "2025-10-17T19:34:55.028Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.55" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/92/dce4842b2e215d213d34b064fcdd13c6a782c43344e77336bcde586e9229/botocore-1.40.55.tar.gz", hash = "sha256:79b6472e2de92b3519d44fc1eec8c5feced7f99a0d10fdea6dc93133426057c1", size = 14446917, upload-time = "2025-10-17T19:34:47.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/30/f13bbc36e83b78777ff1abf50a084efcc3336b808e76560d8c5a0c9219e0/botocore-1.40.55-py3-none-any.whl", hash = "sha256:cdc38f7a4ddb30a2cd1cdd4fabde2a5a16e41b5a642292e1c30de5c4e46f5d44", size = 14116107, upload-time = "2025-10-17T19:34:44.398Z" }, +] + +[[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 = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[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/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { 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 = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +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/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 = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[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/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { 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 = "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/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { 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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[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 = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/1b/6f2697b51eaca81f08852fd2734745af15718fea10222a1d40f8a239c4ea/markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c", size = 18771, upload-time = "2025-08-09T17:44:15.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, +] + +[[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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { 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" }, +] + +[[package]] +name = "mcp" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { 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" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/ed/9c65cd209407fd807fa05be03ee30f159bdac8d59e7ea16a8fe5a1601222/opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc", size = 31544, upload-time = "2025-10-16T08:39:31.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/f5/7a40ff3f62bfe715dad2f633d7f1174ba1a7dd74254c15b2558b3401262a/opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee", size = 33020, upload-time = "2025-10-16T08:38:31.463Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/7a/84e97d8992808197006e607ae410c2219bdbbc23d1289ba0c244d3220741/opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3", size = 8770, upload-time = "2025-10-16T08:40:03.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/32d29076aaa1c91983cdd3ca8c6bb4d344830cd7d87a7c0fdc2d98c58509/opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca", size = 9313, upload-time = "2025-10-16T08:39:15.876Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/cb/f0eee1445161faf4c9af3ba7b848cc22a50a3d3e2515051ad8628c35ff80/opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe", size = 171942, upload-time = "2025-10-16T08:36:02.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/2e/e93777a95d7d9c40d270a371392b6d6f1ff170c2a3cb32d6176741b5b723/opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b", size = 132349, upload-time = "2025-10-16T08:35:46.995Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "prance" +version = "25.4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "packaging" }, + { name = "requests" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/5c/afa384b91354f0dbc194dfbea89bbd3e07dbe47d933a0a2c4fb989fc63af/prance-25.4.8.0.tar.gz", hash = "sha256:2f72d2983d0474b6f53fd604eb21690c1ebdb00d79a6331b7ec95fb4f25a1f65", size = 2808091, upload-time = "2025-04-07T22:22:36.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/a8/fc509e514c708f43102542cdcbc2f42dc49f7a159f90f56d072371629731/prance-25.4.8.0-py3-none-any.whl", hash = "sha256:d3c362036d625b12aeee495621cb1555fd50b2af3632af3d825176bfb50e073b", size = 36386, upload-time = "2025-04-07T22:22:35.183Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { 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" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "py-openapi-schema-to-json-schema" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/c5/5d6a9b08df175a886b4085eb51e0351854a96e4896a367b2373ad19d881b/py-openapi-schema-to-json-schema-0.0.3.tar.gz", hash = "sha256:d557afb6bcc45d62a1383ada0ad57515421552efa3b2e07b2264e5b9e1e9634e", size = 5964, upload-time = "2020-07-25T05:34:52.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/1a/a43f73b8762512ab3358aac96c6c6d1d9ec4dbb3bbb99d82c2e90e5f3d16/py_openapi_schema_to_json_schema-0.0.3-py3-none-any.whl", hash = "sha256:456802186309257a9667fd50eca7c6ff6eaf9930ab09dcc87c54537e01066f09", size = 6954, upload-time = "2020-07-25T05:34:50.932Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +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/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 = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[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 = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e9/39ec4d4b3f91188fad1842748f67d4e749c77c37e353c4e545052ee8e893/ruamel.yaml.clib-0.2.14.tar.gz", hash = "sha256:803f5044b13602d58ea378576dd75aa759f52116a0232608e8fdada4da33752e", size = 225394, upload-time = "2025-09-22T19:51:23.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/42/ccfb34a25289afbbc42017e4d3d4288e61d35b2e00cfc6b92974a6a1f94b/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6aeadc170090ff1889f0d2c3057557f9cd71f975f17535c26a5d37af98f19c27", size = 271775, upload-time = "2025-09-23T14:24:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/e628a92e80197ff6a79ab81ec3fa00d4cc082d58ab78d3337b7ba7043301/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5e56ac47260c0eed992789fa0b8efe43404a9adb608608631a948cee4fc2b052", size = 138842, upload-time = "2025-09-22T19:50:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c5/346c7094344a60419764b4b1334d9e0285031c961176ff88ffb652405b0c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:a911aa73588d9a8b08d662b9484bc0567949529824a55d3885b77e8dd62a127a", size = 647404, upload-time = "2025-09-22T19:50:52.921Z" }, + { url = "https://files.pythonhosted.org/packages/df/99/65080c863eb06d4498de3d6c86f3e90595e02e159fd8529f1565f56cfe2c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a05ba88adf3d7189a974b2de7a9d56731548d35dc0a822ec3dc669caa7019b29", size = 753141, upload-time = "2025-09-22T19:50:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e3/0de85f3e3333f8e29e4b10244374a202a87665d1131798946ee22cf05c7c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb04c5650de6668b853623eceadcdb1a9f2fee381f5d7b6bc842ee7c239eeec4", size = 703477, upload-time = "2025-09-22T19:50:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/0d2f09d8833c7fd77ab8efeff213093c16856479a9d293180a0d89f6bed9/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df3ec9959241d07bc261f4983d25a1205ff37703faf42b474f15d54d88b4f8c9", size = 741157, upload-time = "2025-09-23T18:42:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/959f10c2e2153cbdab834c46e6954b6dd9e3b109c8f8c0a3cf1618310985/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259", size = 745859, upload-time = "2025-09-22T19:50:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6b/e580a7c18b485e1a5f30a32cda96b20364b0ba649d9d2baaf72f8bd21f83/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c099cafc1834d3c5dac305865d04235f7c21c167c8dd31ebc3d6bbc357e2f023", size = 770200, upload-time = "2025-09-22T19:50:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/ef/44/3455eebc761dc8e8fdced90f2b0a3fa61e32ba38b50de4130e2d57db0f21/ruamel.yaml.clib-0.2.14-cp312-cp312-win32.whl", hash = "sha256:b5b0f7e294700b615a3bcf6d28b26e6da94e8eba63b079f4ec92e9ba6c0d6b54", size = 98829, upload-time = "2025-09-22T19:50:58.895Z" }, + { url = "https://files.pythonhosted.org/packages/76/ab/5121f7f3b651db93de546f8c982c241397aad0a4765d793aca1dac5eadee/ruamel.yaml.clib-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:a37f40a859b503304dd740686359fcf541d6fb3ff7fc10f539af7f7150917c68", size = 115570, upload-time = "2025-09-22T19:50:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ae/e3811f05415594025e96000349d3400978adaed88d8f98d494352d9761ee/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7e4f9da7e7549946e02a6122dcad00b7c1168513acb1f8a726b1aaf504a99d32", size = 269205, upload-time = "2025-09-23T14:24:15.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/7d51f4688d6d72bb72fa74254e1593c4f5ebd0036be5b41fe39315b275e9/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:dd7546c851e59c06197a7c651335755e74aa383a835878ca86d2c650c07a2f85", size = 137417, upload-time = "2025-09-22T19:50:59.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/08/b4499234a420ef42960eeb05585df5cc7eb25ccb8c980490b079e6367050/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:1c1acc3a0209ea9042cc3cfc0790edd2eddd431a2ec3f8283d081e4d5018571e", size = 642558, upload-time = "2025-09-22T19:51:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ba/1975a27dedf1c4c33306ee67c948121be8710b19387aada29e2f139c43ee/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2070bf0ad1540d5c77a664de07ebcc45eebd1ddcab71a7a06f26936920692beb", size = 744087, upload-time = "2025-09-22T19:51:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/20/15/8a19a13d27f3bd09fa18813add8380a29115a47b553845f08802959acbce/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd8fe07f49c170e09d76773fb86ad9135e0beee44f36e1576a201b0676d3d1d", size = 699709, upload-time = "2025-09-22T19:51:02.075Z" }, + { url = "https://files.pythonhosted.org/packages/19/ee/8d6146a079ad21e534b5083c9ee4a4c8bec42f79cf87594b60978286b39a/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59", size = 708926, upload-time = "2025-09-23T18:42:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/426b714abdc222392e68f3b8ad323930d05a214a27c7e7a0f06c69126401/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1f118b707eece8cf84ecbc3e3ec94d9db879d85ed608f95870d39b2d2efa5dca", size = 740202, upload-time = "2025-09-22T19:51:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ac/3c5c2b27a183f4fda8a57c82211721c016bcb689a4a175865f7646db9f94/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b30110b29484adc597df6bd92a37b90e63a8c152ca8136aad100a02f8ba6d1b6", size = 765196, upload-time = "2025-09-22T19:51:05.916Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/06f56a71fd55021c993ed6e848c9b2e5e9cfce180a42179f0ddd28253f7c/ruamel.yaml.clib-0.2.14-cp313-cp313-win32.whl", hash = "sha256:f4e97a1cf0b7a30af9e1d9dad10a5671157b9acee790d9e26996391f49b965a2", size = 98635, upload-time = "2025-09-22T19:51:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/51/79/76aba16a1689b50528224b182f71097ece338e7a4ab55e84c2e73443b78a/ruamel.yaml.clib-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:090782b5fb9d98df96509eecdbcaffd037d47389a89492320280d52f91330d78", size = 115238, upload-time = "2025-09-22T19:51:07.081Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/a59ff65c26aaf21a24eb38df777cb9af5d87ba8fc8107c163c2da9d1e85e/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7df6f6e9d0e33c7b1d435defb185095386c469109de723d514142632a7b9d07f", size = 271441, upload-time = "2025-09-23T14:24:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[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 = "slack-bolt" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/14/0f490731fbfc95b5711e8124b30bb6e2a4be5edad22256891adad66f8b79/slack_bolt-1.26.0.tar.gz", hash = "sha256:b0b806b9dcf009ee50172830c1d170e231cd873c5b819703bbcdc59a0fe5ff3e", size = 129915, upload-time = "2025-10-06T23:41:51.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/77/57aff95f88f2f1a959088ff29c45ceaf8dcad540e9966b647d6942a007f0/slack_bolt-1.26.0-py2.py3-none-any.whl", hash = "sha256:d8386ecb27aaa487c1a5e4b43a4125f532100fc3a26e49dd2a66f5837ff2e3be", size = 230084, upload-time = "2025-10-06T23:41:50.118Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.37.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/c2/0a174a155623d7dc3ed4d1360cdf755590acdc2c3fc9ce0d2340f468909f/slack_sdk-3.37.0.tar.gz", hash = "sha256:242d6cffbd9e843af807487ff04853189b812081aeaa22f90a8f159f20220ed9", size = 241612, upload-time = "2025-10-06T23:07:20.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/fd/a502ee24d8c7d12a8f749878ae0949b8eeb50aeac22dc5a613d417a256d0/slack_sdk-3.37.0-py2.py3-none-any.whl", hash = "sha256:e108a0836eafda74d8a95e76c12c2bcb010e645d504d8497451e4c7ebb229c87", size = 302751, upload-time = "2025-10-06T23:07:19.542Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[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/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, + { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, + { 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]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "strands-agents" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/78/39bd0254fd9586fec1345f1fb93f13e242af1254d3665b5613f74d4e8eef/strands_agents-1.13.0.tar.gz", hash = "sha256:50a15d9174be62eb2a55b33e966e675632ddb89dab192ba0cf68f3d25beb2f65", size = 430554, upload-time = "2025-10-17T19:01:18.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/29/5617003dd640a005b3b3a00b9a736333b2939942e3bc3a3d9cc976de854a/strands_agents-1.13.0-py3-none-any.whl", hash = "sha256:ac77bce99e55416c54f8d6dbc0301d5a6c6e417dc99dbe6bb445f7c715d89116", size = 223508, upload-time = "2025-10-17T19:01:16.65Z" }, +] + +[[package]] +name = "strands-agents-tools" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aws-requests-auth" }, + { name = "botocore" }, + { name = "dill" }, + { name = "markdownify" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "rich" }, + { name = "slack-bolt" }, + { name = "strands-agents" }, + { name = "sympy" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/6b/af065e011dbb9e09eff8db78fa7d254f9ebae388c3baf15c846c653bc1b2/strands_agents_tools-0.2.12.tar.gz", hash = "sha256:fc653100034390f5a59d3850ef361d7a432efef4be4fa3195ecfcbdc5240e2c4", size = 448959, upload-time = "2025-10-17T19:02:35.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/65/a0688b324a71f3179b04e608a04e9c108bc01e6295be5e74f10e5bb65045/strands_agents_tools-0.2.12-py3-none-any.whl", hash = "sha256:ba9ba1b3c723afdf741d3fa9fa9d21e17cd893258273c50095e8f4d069c2cf84", size = 299449, upload-time = "2025-10-17T19:02:33.503Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[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 = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[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/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { 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" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/backend/agent_reporter/.bedrock_agentcore.yaml b/backend/agent_reporter/.bedrock_agentcore.yaml new file mode 100644 index 00000000..5a969cd5 --- /dev/null +++ b/backend/agent_reporter/.bedrock_agentcore.yaml @@ -0,0 +1,41 @@ +default_agent: reporter +agents: + reporter: + name: reporter + entrypoint: /Users/fotis/Documents/CV/Learning/AI in production/alex/backend/agent_reporter/agent.py + platform: linux/arm64 + container_runtime: docker + source_path: null + aws: + execution_role: arn:aws:iam::717174128108:role/agentcore-reporter-role + execution_role_auto_create: false + account: '717174128108' + region: us-east-1 + ecr_repository: 717174128108.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-reporter + ecr_auto_create: false + network_configuration: + network_mode: PUBLIC + network_mode_config: null + protocol_configuration: + server_protocol: HTTP + observability: + enabled: true + bedrock_agentcore: + agent_id: reporter-h5LXJ22WcM + agent_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:runtime/reporter-h5LXJ22WcM + agent_session_id: null + codebuild: + project_name: bedrock-agentcore-reporter-builder + execution_role: arn:aws:iam::717174128108:role/AmazonBedrockAgentCoreSDKCodeBuild-us-east-1-41d6d5322a + source_bucket: bedrock-agentcore-codebuild-sources-717174128108-us-east-1 + memory: + mode: STM_ONLY + memory_id: reporter_mem-khwPCjC2OV + memory_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:memory/reporter_mem-khwPCjC2OV + memory_name: reporter_mem + event_expiry_days: 30 + first_invoke_memory_check_done: false + was_created_by_toolkit: false + authorizer_configuration: null + request_header_configuration: null + oauth_configuration: null diff --git a/backend/agent_reporter/.dockerignore b/backend/agent_reporter/.dockerignore new file mode 100644 index 00000000..bf13996c --- /dev/null +++ b/backend/agent_reporter/.dockerignore @@ -0,0 +1,69 @@ +# Build artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Python cache +__pycache__/ +__pycache__* +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +.env +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +.coverage* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.mypy_cache/ +.ruff_cache/ + +# Development +*.log +*.bak +*.swp +*.swo +*~ +.DS_Store + +# IDEs +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Project specific +tests/ + +# Bedrock AgentCore specific - keep config but exclude runtime files +.bedrock_agentcore.yaml +.dockerignore +.bedrock_agentcore/ + +# Keep wheelhouse for offline installations +# wheelhouse/ diff --git a/backend/agent_reporter/.gitignore b/backend/agent_reporter/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_reporter/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_reporter/Dockerfile b/backend/agent_reporter/Dockerfile new file mode 100644 index 00000000..cad003d5 --- /dev/null +++ b/backend/agent_reporter/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app + +# All environment variables in one layer +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + AWS_REGION=us-east-1 \ + AWS_DEFAULT_REGION=us-east-1 \ + BEDROCK_AGENTCORE_MEMORY_ID=reporter_mem-khwPCjC2OV \ + BEDROCK_AGENTCORE_MEMORY_NAME=reporter_mem + + + +COPY requirements.txt requirements.txt +# Install from requirements file +RUN uv pip install -r requirements.txt + + + + +RUN uv pip install aws-opentelemetry-distro>=0.10.1 + + +# Signal that this is running in Docker for host binding logic +ENV DOCKER_CONTAINER=1 + +# Create non-root user +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 9000 +EXPOSE 8000 +EXPOSE 8080 + +# Copy entire project (respecting .dockerignore) +COPY . . + +# Use the full module path + +CMD ["opentelemetry-instrument", "python", "-m", "agent"] diff --git a/backend/agent_reporter/agent.py b/backend/agent_reporter/agent.py new file mode 100644 index 00000000..384857cc --- /dev/null +++ b/backend/agent_reporter/agent.py @@ -0,0 +1,511 @@ +""" +Report Writer Agent - generates portfolio analysis narratives using Bedrock AgentCore. +""" + +import os +import json +import logging +import asyncio +from typing import Dict, Any, List, Optional +from dataclasses import dataclass +from datetime import datetime + +# Load environment variables from SSM at startup +import sys +sys.path.append('/opt/python') # Add common layer path if available +try: + from utils import load_env_from_ssm + load_env_from_ssm() + print("✅ Loaded environment variables from SSM") +except Exception as e: + print(f"⚠️ Could not load environment from SSM: {e}") + # Fallback to local .env file + try: + from dotenv import load_dotenv + load_dotenv() + print("✅ Loaded environment variables from .env file") + except ImportError: + print("⚠️ python-dotenv not available, skipping .env file loading") + except Exception as e2: + print(f"⚠️ Could not load .env file: {e2}") + +from strands import Agent, tool +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +# Add current directory to Python path for src imports +import sys +import os +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# Import database package +from src import Database + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Get configuration +model_id = os.getenv("BEDROCK_MODEL_ID", "us.anthropic.claude-3-7-sonnet-20250219-v1:0") +BEDROCK_REGION = os.getenv("BEDROCK_REGION", "us-west-2") + +db = Database() + +# Reporter instructions +REPORTER_INSTRUCTIONS = """You are an expert portfolio analyst responsible for generating comprehensive investment reports. + +Your task is to analyze portfolio data and create detailed, professional reports that help investors understand their current position and make informed decisions. + +Key responsibilities: +1. Analyze portfolio composition, diversification, and risk profile +2. Evaluate alignment with retirement goals and timeline +3. Provide actionable recommendations +4. Include relevant market context when available +5. Write in clear, accessible language for retail investors + +Important guidelines: +- Be objective and data-driven in your analysis +- Highlight both strengths and areas for improvement +- Provide specific, actionable recommendations +- Use markdown formatting for clear structure +- Include relevant financial metrics and percentages +""" + + +def calculate_portfolio_metrics(portfolio_data: Dict[str, Any]) -> Dict[str, Any]: + """Calculate basic portfolio metrics.""" + metrics = { + "total_value": 0, + "cash_balance": 0, + "num_accounts": len(portfolio_data.get("accounts", [])), + "num_positions": 0, + "unique_symbols": set(), + } + + for account in portfolio_data.get("accounts", []): + metrics["cash_balance"] += float(account.get("cash_balance", 0)) + positions = account.get("positions", []) + metrics["num_positions"] += len(positions) + + for position in positions: + symbol = position.get("symbol") + if symbol: + metrics["unique_symbols"].add(symbol) + + # Calculate value if we have price + instrument = position.get("instrument", {}) + if instrument.get("current_price"): + value = float(position.get("quantity", 0)) * float(instrument["current_price"]) + metrics["total_value"] += value + + metrics["total_value"] += metrics["cash_balance"] + metrics["unique_symbols"] = len(metrics["unique_symbols"]) + + return metrics + + +def format_portfolio_for_analysis(portfolio_data: Dict[str, Any], user_data: Dict[str, Any]) -> str: + """Format portfolio data for agent analysis.""" + metrics = calculate_portfolio_metrics(portfolio_data) + + lines = [ + f"Portfolio Overview:", + f"- {metrics['num_accounts']} accounts", + f"- {metrics['num_positions']} total positions", + f"- {metrics['unique_symbols']} unique holdings", + f"- ${metrics['cash_balance']:,.2f} in cash", + f"- ${metrics['total_value']:,.2f} total value" if metrics["total_value"] > 0 else "", + "", + "Account Details:", + ] + + for account in portfolio_data.get("accounts", []): + name = account.get("account_name", account.get("name", "Unknown")) # Support both field names for backward compatibility + cash = float(account.get("cash_balance", 0)) + lines.append(f"\n{name} (${cash:,.2f} cash):") + + for position in account.get("positions", []): + symbol = position.get("symbol") + quantity = float(position.get("quantity", 0)) + instrument = position.get("instrument", {}) + name = instrument.get("name", "") + + # Include allocation info if available + allocations = [] + if instrument.get("allocation_asset_class"): + asset_class = ", ".join([f"{k}: {v}%" for k, v in instrument["allocation_asset_class"].items()]) + allocations.append(f"Asset: {asset_class}") + if instrument.get("allocation_regions"): + regions = ", ".join([f"{k}: {v}%" for k, v in list(instrument["allocation_regions"].items())[:2]]) + allocations.append(f"Regions: {regions}") + + alloc_str = f" ({', '.join(allocations)})" if allocations else "" + lines.append(f" - {symbol}: {quantity:,.2f} shares{alloc_str}") + + # Add user context + lines.extend( + [ + "", + "User Profile:", + f"- Years to retirement: {user_data.get('years_until_retirement', 'Not specified')}", + f"- Target retirement income: ${user_data.get('target_retirement_income', 0):,.0f}/year", + ] + ) + + return "\n".join(lines) + + +@tool +async def get_market_insights(symbols: List[str]) -> str: + """ + Retrieve market insights from S3 Vectors knowledge base. + + Args: + symbols: List of symbols to get insights for + + Returns: + Relevant market context and insights + """ + try: + import boto3 + + # Get account ID + sts = boto3.client("sts") + account_id = sts.get_caller_identity()["Account"] + bucket = f"alex-vectors-fotis" + + # Get embeddings + sagemaker_region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + sagemaker = boto3.client("sagemaker-runtime", region_name=sagemaker_region) + endpoint_name = os.getenv("SAGEMAKER_ENDPOINT", "alex-embedding-endpoint") + query = f"market analysis {' '.join(symbols[:5])}" if symbols else "market outlook" + + response = sagemaker.invoke_endpoint( + EndpointName=endpoint_name, + ContentType="application/json", + Body=json.dumps({"inputs": query}), + ) + + result = json.loads(response["Body"].read().decode()) + # Extract embedding (handle nested arrays) + if isinstance(result, list) and result: + embedding = result[0][0] if isinstance(result[0], list) else result[0] + else: + embedding = result + + # Search vectors + s3v = boto3.client("s3vectors", region_name=sagemaker_region) + response = s3v.query_vectors( + vectorBucketName=bucket, + indexName="financial-research", + queryVector={"float32": embedding}, + topK=3, + returnMetadata=True, + ) + + # Format insights + insights = [] + for vector in response.get("vectors", []): + metadata = vector.get("metadata", {}) + text = metadata.get("text", "")[:200] + if text: + company = metadata.get("company_name", "") + prefix = f"{company}: " if company else "- " + insights.append(f"{prefix}{text}...") + + if insights: + return "Market Insights:\n" + "\n".join(insights) + else: + return "Market insights unavailable - proceeding with standard analysis." + + except Exception as e: + logger.warning(f"Reporter: Could not retrieve market insights: {e}") + return "Market insights unavailable - proceeding with standard analysis." + + +async def create_agent_and_run(job_id: str, portfolio_data: Dict[str, Any], user_data: Dict[str, Any], db=None): + """Create and run the reporter agent with tools and context.""" + try: + # Create model + model = BedrockModel( + model_id=model_id, + ) + + # Create agent + agent = Agent( + model=model, + system_prompt=REPORTER_INSTRUCTIONS, + tools=[get_market_insights] + ) + + # Format portfolio for analysis + portfolio_summary = format_portfolio_for_analysis(portfolio_data, user_data) + + # Create task + task = f"""Analyze this investment portfolio and write a comprehensive report. + +{portfolio_summary} + +Your task: +1. First, get market insights for the top holdings using get_market_insights() +2. Analyze the portfolio's current state, strengths, and weaknesses +3. Generate a detailed, professional analysis report in markdown format + +The report should include: +- Executive Summary +- Portfolio Composition Analysis +- Risk Assessment +- Diversification Analysis +- Retirement Readiness (based on user goals) +- Recommendations +- Market Context (from insights) + +Provide your complete analysis as the final output in clear markdown format. +Make the report informative yet accessible to a retail investor.""" + + # Run the agent - the call itself is async, but the result is not + result = agent(task) + + # Extract the text content from the AgentResult + response = result.text if hasattr(result, 'text') else str(result) + + return response + + except Exception as e: + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + logger.warning(f"Reporter agent reached max tokens for job {job_id}: {e}") + return """# Portfolio Analysis Report (Partial) + +**Note: This analysis was stopped due to reaching maximum token limit. This typically happens with very large or complex portfolios.** + +## Executive Summary +Your portfolio analysis was initiated but could not be completed due to system limitations. This often occurs when: +- The portfolio contains a very large number of holdings +- The portfolio data is extremely detailed or complex +- Multiple complex analysis steps were required + +## Recommendations +1. **Contact Support**: For assistance with large portfolio analysis +2. **Simplify Analysis**: Consider analyzing smaller segments of your portfolio +3. **Reduce Complexity**: Focus on major holdings for initial analysis + +We apologize for the incomplete analysis. Please contact support for assistance with complex portfolio analysis.""" + else: + logger.error(f"Reporter agent error for job {job_id}: {e}") + raise + + +async def process_portfolio_report( + job_id: str, portfolio_data: Dict[str, Any], user_data: Dict[str, Any] +) -> Dict[str, Any]: + """ + Process and generate portfolio report. + + Args: + job_id: Unique job identifier + portfolio_data: Portfolio data to analyze + user_data: User preferences and goals + + Returns: + Processing results + """ + try: + # Run the agent + logger.info(f"Generating report for job {job_id}") + response = await create_agent_and_run(job_id, portfolio_data, user_data, db) + + # Save the report to database + report_payload = { + "content": response, + "generated_at": datetime.utcnow().isoformat(), + "agent": "reporter", + } + + success = db.jobs.update_report(job_id, report_payload) + + if not success: + logger.error(f"Failed to save report for job {job_id}") + # Add debugging - check if job exists + job = db.jobs.find_by_id(job_id) + if job: + logger.error(f"Job exists but update failed. Job status: {job.get('status')}") + else: + logger.error(f"Job {job_id} does not exist in database") + + return { + "success": success, + "message": "Report generated and stored" if success else "Report generated but failed to save", + "final_output": response, + } + + except Exception as e: + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + logger.warning(f"Reporter agent reached max tokens for job {job_id}: {e}") + return { + "success": True, # Consider this a successful partial result + "max_tokens_exceeded": True, + "message": "Report partially generated - stopped due to max tokens limit", + "final_output": "Portfolio analysis was stopped due to reaching maximum token limit. This typically happens with very large or complex portfolios. Please contact support for assistance.", + } + else: + logger.error(f"Error processing portfolio report for {job_id}: {e}") + return { + "success": False, + "error": str(e), + "message": f"Failed to generate report: {str(e)}" + } + + +app = BedrockAgentCoreApp() + + +@app.entrypoint +def reporter_agent(payload): + """Main entry point for the reporter agent.""" + try: + logger.info(f"Reporter Agent invoked with payload: {json.dumps(payload)[:500]}") + + # Parse the payload + job_id = payload.get("job_id") + if not job_id: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'job_id is required'}) + } + + portfolio_data = payload.get("portfolio_data") + user_data = payload.get("user_data", {}) + + # If no portfolio data provided, try to load from database + if not portfolio_data: + try: + job = db.jobs.find_by_id(job_id) + if job: + user_id = job["clerk_user_id"] + user = db.users.find_by_clerk_id(user_id) + accounts = db.accounts.find_by_user(user_id) + + portfolio_data = {"user_id": user_id, "job_id": job_id, "accounts": []} + + for account in accounts: + positions = db.positions.find_by_account(account["id"]) + account_data = { + "id": account["id"], + "name": account["account_name"], + "type": account.get("account_type", "investment"), + "cash_balance": float(account.get("cash_balance", 0)), + "positions": [], + } + + for position in positions: + instrument = db.instruments.find_by_symbol(position["symbol"]) + if instrument: + account_data["positions"].append( + { + "symbol": position["symbol"], + "quantity": float(position["quantity"]), + "instrument": instrument, + } + ) + + portfolio_data["accounts"].append(account_data) + else: + return { + "statusCode": 404, + "body": json.dumps({"error": f"Job {job_id} not found"}), + } + except Exception as e: + logger.error(f"Could not load portfolio from database: {e}") + return { + "statusCode": 400, + "body": json.dumps({"error": "No portfolio data provided"}), + } + + # If no user data provided, try to load from database + if not user_data: + try: + job = db.jobs.find_by_id(job_id) + if job and job.get("clerk_user_id"): + user = db.users.find_by_clerk_id(job["clerk_user_id"]) + if user: + user_data = { + "years_until_retirement": user.get("years_until_retirement", 30), + "target_retirement_income": float( + user.get("target_retirement_income", 80000) + ), + } + else: + user_data = { + "years_until_retirement": 30, + "target_retirement_income": 80000, + } + except Exception as e: + logger.warning(f"Could not load user data: {e}. Using defaults.") + user_data = {"years_until_retirement": 30, "target_retirement_income": 80000} + + # Process the report in a single async context + result = asyncio.run(process_portfolio_report(job_id, portfolio_data, user_data)) + + return { + 'statusCode': 200, + 'body': json.dumps(result) + } + + except Exception as e: + # Check if this is a MaxTokensReachedException + if 'max_tokens' in str(e).lower() or 'maxtokensreachedException' in str(e) or e.__class__.__name__ == 'MaxTokensReachedException': + logger.warning(f"Reporter agent reached max tokens: {e}") + return { + 'statusCode': 200, # Return success with explanation + 'body': json.dumps({ + 'success': True, + 'max_tokens_exceeded': True, + 'message': 'Report partially generated - stopped due to max tokens limit', + 'final_output': 'Portfolio analysis was stopped due to reaching maximum token limit. This typically happens with very large or complex portfolios. Please contact support for assistance.', + 'error': str(e) + }) + } + else: + logger.error(f"Reporter agent error: {e}", exc_info=True) + return { + 'statusCode': 500, + 'body': json.dumps({'error': str(e)}) + } + + +if __name__ == "__main__": + app.run() + # Simple test when run directly + # async def test(): + # payload = { + # "job_id": "550e8400-e29b-41d4-a716-446655440002", + # "portfolio_data": { + # "accounts": [ + # { + # "name": "401(k)", + # "cash_balance": 5000, + # "positions": [ + # { + # "symbol": "SPY", + # "quantity": 100, + # "instrument": { + # "name": "SPDR S&P 500 ETF", + # "current_price": 450, + # "allocation_asset_class": {"equity": 100.0}, + # "allocation_regions": {"north_america": 100.0}, + # "allocation_sectors": {"technology": 30.0, "healthcare": 15.0, "financials": 13.0, "other": 42.0} + # }, + # } + # ], + # } + # ] + # }, + # "user_data": {"years_until_retirement": 25, "target_retirement_income": 75000}, + # } + # result = await process_portfolio_report(payload["job_id"], payload["portfolio_data"], payload["user_data"]) + # print(json.dumps(result, indent=2)) + + # asyncio.run(test()) diff --git a/backend/agent_reporter/requirements.txt b/backend/agent_reporter/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_reporter/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_reporter/src/__init__.py b/backend/agent_reporter/src/__init__.py new file mode 100644 index 00000000..5bc75e95 --- /dev/null +++ b/backend/agent_reporter/src/__init__.py @@ -0,0 +1,51 @@ +""" +Database package for Alex Financial Planner +Provides database models, schemas, and Data API client +""" + +from .client import DataAPIClient +from .models import Database +from .schemas import ( + # Types + RegionType, + AssetClassType, + SectorType, + InstrumentType, + JobType, + JobStatus, + AccountType, + + # Create schemas (for inputs) + InstrumentCreate, + UserCreate, + AccountCreate, + PositionCreate, + JobCreate, + JobUpdate, + + # Response schemas (for outputs) + InstrumentResponse, + PortfolioAnalysis, + RebalanceRecommendation, +) + +__all__ = [ + 'Database', + 'DataAPIClient', + 'InstrumentCreate', + 'UserCreate', + 'AccountCreate', + 'PositionCreate', + 'JobCreate', + 'JobUpdate', + 'InstrumentResponse', + 'PortfolioAnalysis', + 'RebalanceRecommendation', + 'RegionType', + 'AssetClassType', + 'SectorType', + 'InstrumentType', + 'JobType', + 'JobStatus', + 'AccountType', +] \ No newline at end of file diff --git a/backend/agent_reporter/src/client.py b/backend/agent_reporter/src/client.py new file mode 100644 index 00000000..f91994e9 --- /dev/null +++ b/backend/agent_reporter/src/client.py @@ -0,0 +1,310 @@ +""" +Aurora Data API Client Wrapper +Provides a simple interface for database operations +""" + +import boto3 +import json +import os +from typing import List, Dict, Any, Optional, Tuple +from datetime import date, datetime +from decimal import Decimal +from botocore.exceptions import ClientError +import logging + +# Try to load .env file if it exists +try: + from dotenv import load_dotenv + + load_dotenv(override=True) +except ImportError: + pass # dotenv not installed, continue without it + +logger = logging.getLogger(__name__) + + +class DataAPIClient: + """Wrapper for AWS RDS Data API to simplify database operations""" + + def __init__( + self, + cluster_arn: str = None, + secret_arn: str = None, + database: str = None, + region: str = None, + ): + """ + Initialize Data API client + + Args: + cluster_arn: Aurora cluster ARN (or from env AURORA_CLUSTER_ARN) + secret_arn: Secrets Manager ARN (or from env AURORA_SECRET_ARN) + database: Database name (or from env AURORA_DATABASE) + region: AWS region (or from env AWS_REGION) + """ + self.cluster_arn = cluster_arn or os.environ.get("AURORA_CLUSTER_ARN") + self.secret_arn = secret_arn or os.environ.get("AURORA_SECRET_ARN") + self.database = database or os.environ.get("AURORA_DATABASE", "alex") + + if not self.cluster_arn or not self.secret_arn: + raise ValueError( + "Missing required Aurora configuration. " + "Set AURORA_CLUSTER_ARN and AURORA_SECRET_ARN environment variables." + ) + + self.region = os.environ.get("DEFAULT_AWS_REGION", "us-east-1") + self.client = boto3.client("rds-data", region_name=self.region) + + def execute(self, sql: str, parameters: List[Dict] = None) -> Dict: + """ + Execute a SQL statement + + Args: + sql: SQL statement to execute + parameters: Optional list of parameters for prepared statement + + Returns: + Response from Data API + """ + try: + kwargs = { + "resourceArn": self.cluster_arn, + "secretArn": self.secret_arn, + "database": self.database, + "sql": sql, + "includeResultMetadata": True, # Include column names + } + + if parameters: + kwargs["parameters"] = parameters + + response = self.client.execute_statement(**kwargs) + return response + + except ClientError as e: + logger.error(f"Database error: {e}") + raise + + def query(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """ + Execute a SELECT query and return results as list of dicts + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + List of dictionaries with column names as keys + """ + response = self.execute(sql, parameters) + + if "records" not in response: + return [] + + # Extract column names + columns = [col["name"] for col in response.get("columnMetadata", [])] + + # Convert records to dictionaries + results = [] + for record in response["records"]: + row = {} + for i, col in enumerate(columns): + value = self._extract_value(record[i]) + row[col] = value + results.append(row) + + return results + + def query_one(self, sql: str, parameters: List[Dict] = None) -> Optional[Dict]: + """ + Execute a SELECT query and return first result + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + Dictionary with column names as keys, or None if no results + """ + results = self.query(sql, parameters) + return results[0] if results else None + + def insert(self, table: str, data: Dict, returning: str = None) -> str: + """ + Insert a record into a table + + Args: + table: Table name + data: Dictionary of column names and values + returning: Column to return (e.g., 'id', 'clerk_user_id') + + Returns: + Value of returning column if specified + """ + columns = list(data.keys()) + placeholders = [] + + # Check if columns need type casting + for col in columns: + if isinstance(data[col], (dict, list)): + placeholders.append(f":{col}::jsonb") + elif isinstance(data[col], Decimal): + placeholders.append(f":{col}::numeric") + elif isinstance(data[col], date) and not isinstance(data[col], datetime): + placeholders.append(f":{col}::date") + elif isinstance(data[col], datetime): + placeholders.append(f":{col}::timestamp") + else: + placeholders.append(f":{col}") + + sql = f""" + INSERT INTO {table} ({", ".join(columns)}) + VALUES ({", ".join(placeholders)}) + """ + + # Add RETURNING clause if specified + if returning: + sql += f" RETURNING {returning}" + + parameters = self._build_parameters(data) + response = self.execute(sql, parameters) + + # Return value if RETURNING was used + if returning and response.get("records"): + return self._extract_value(response["records"][0][0]) + return None + + def update(self, table: str, data: Dict, where: str, where_params: Dict = None) -> int: + """ + Update records in a table + + Args: + table: Table name + data: Dictionary of columns to update + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of affected rows + """ + # Build SET clause with type casting where needed + set_parts = [] + for col, val in data.items(): + if isinstance(val, (dict, list)): + set_parts.append(f"{col} = :{col}::jsonb") + elif isinstance(val, Decimal): + set_parts.append(f"{col} = :{col}::numeric") + elif isinstance(val, date) and not isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::date") + elif isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::timestamp") + else: + set_parts.append(f"{col} = :{col}") + + set_clause = ", ".join(set_parts) + + sql = f""" + UPDATE {table} + SET {set_clause} + WHERE {where} + """ + + # Combine data and where parameters + all_params = {**data, **(where_params or {})} + parameters = self._build_parameters(all_params) + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def delete(self, table: str, where: str, where_params: Dict = None) -> int: + """ + Delete records from a table + + Args: + table: Table name + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of deleted rows + """ + sql = f"DELETE FROM {table} WHERE {where}" + parameters = self._build_parameters(where_params) if where_params else None + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def begin_transaction(self) -> str: + """Begin a database transaction""" + response = self.client.begin_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, database=self.database + ) + return response["transactionId"] + + def commit_transaction(self, transaction_id: str): + """Commit a database transaction""" + self.client.commit_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def rollback_transaction(self, transaction_id: str): + """Rollback a database transaction""" + self.client.rollback_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def _build_parameters(self, data: Dict) -> List[Dict]: + """Convert dictionary to Data API parameter format""" + if not data: + return [] + + parameters = [] + for key, value in data.items(): + param = {"name": key} + + if value is None: + param["value"] = {"isNull": True} + elif isinstance(value, bool): + param["value"] = {"booleanValue": value} + elif isinstance(value, int): + param["value"] = {"longValue": value} + elif isinstance(value, float): + param["value"] = {"doubleValue": value} + elif isinstance(value, Decimal): + param["value"] = {"stringValue": str(value)} + elif isinstance(value, (date, datetime)): + param["value"] = {"stringValue": value.isoformat()} + elif isinstance(value, dict): + param["value"] = {"stringValue": json.dumps(value)} + elif isinstance(value, list): + param["value"] = {"stringValue": json.dumps(value)} + else: + param["value"] = {"stringValue": str(value)} + + parameters.append(param) + + return parameters + + def _extract_value(self, field: Dict) -> Any: + """Extract value from Data API field response""" + if field.get("isNull"): + return None + elif "booleanValue" in field: + return field["booleanValue"] + elif "longValue" in field: + return field["longValue"] + elif "doubleValue" in field: + return field["doubleValue"] + elif "stringValue" in field: + value = field["stringValue"] + # Try to parse JSON if it looks like JSON + if value and value[0] in ["{", "["]: + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + elif "blobValue" in field: + return field["blobValue"] + else: + return None diff --git a/backend/agent_reporter/src/models.py b/backend/agent_reporter/src/models.py new file mode 100644 index 00000000..903e3594 --- /dev/null +++ b/backend/agent_reporter/src/models.py @@ -0,0 +1,320 @@ +""" +Database models and query builders +""" + +from typing import Dict, List, Optional, Any +from datetime import datetime, date +from decimal import Decimal +from .client import DataAPIClient +from .schemas import ( + InstrumentCreate, UserCreate, AccountCreate, + PositionCreate, JobCreate, JobUpdate +) + + +class BaseModel: + """Base class for database models""" + + table_name = None + + def __init__(self, db: DataAPIClient): + self.db = db + if not self.table_name: + raise ValueError("table_name must be defined") + + def find_by_id(self, id: Any) -> Optional[Dict]: + """Find a record by ID""" + sql = f"SELECT * FROM {self.table_name} WHERE id = :id::uuid" + return self.db.query_one(sql, [{'name': 'id', 'value': {'stringValue': str(id)}}]) + + def find_all(self, limit: int = 100, offset: int = 0) -> List[Dict]: + """Find all records with pagination""" + sql = f"SELECT * FROM {self.table_name} LIMIT :limit OFFSET :offset" + params = [ + {'name': 'limit', 'value': {'longValue': limit}}, + {'name': 'offset', 'value': {'longValue': offset}} + ] + return self.db.query(sql, params) + + def create(self, data: Dict, returning: str = 'id') -> str: + """Create a new record""" + return self.db.insert(self.table_name, data, returning=returning) + + def update(self, id: Any, data: Dict) -> int: + """Update a record by ID""" + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': str(id)}) + + def delete(self, id: Any) -> int: + """Delete a record by ID""" + return self.db.delete(self.table_name, "id = :id::uuid", {'id': str(id)}) + + +class Users(BaseModel): + """Users table operations""" + table_name = 'users' + + def find_by_clerk_id(self, clerk_user_id: str) -> Optional[Dict]: + """Find user by Clerk ID""" + sql = f"SELECT * FROM {self.table_name} WHERE clerk_user_id = :clerk_id" + params = [{'name': 'clerk_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query_one(sql, params) + + def create_user(self, clerk_user_id: str, display_name: str = None, + years_until_retirement: int = None, + target_retirement_income: Decimal = None) -> str: + """Create a new user""" + data = { + 'clerk_user_id': clerk_user_id, + 'display_name': display_name, + 'years_until_retirement': years_until_retirement, + 'target_retirement_income': target_retirement_income + } + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + return self.db.insert(self.table_name, data, returning='clerk_user_id') + + +class Instruments(BaseModel): + """Instruments table operations""" + table_name = 'instruments' + + def find_all(self, limit: int = None, offset: int = 0) -> List[Dict]: + """Find all instruments - no limit by default for autocomplete""" + sql = f"SELECT * FROM {self.table_name} ORDER BY symbol" + return self.db.query(sql, []) + + def find_by_symbol(self, symbol: str) -> Optional[Dict]: + """Find instrument by symbol""" + sql = f"SELECT * FROM {self.table_name} WHERE symbol = :symbol" + params = [{'name': 'symbol', 'value': {'stringValue': symbol}}] + return self.db.query_one(sql, params) + + def create_instrument(self, instrument: InstrumentCreate) -> str: + """Create a new instrument with validation""" + # Validate using Pydantic + validated = instrument.model_dump() + + # Convert allocations to JSON strings for storage + data = { + 'symbol': validated['symbol'], + 'name': validated['name'], + 'instrument_type': validated['instrument_type'], + 'allocation_regions': validated['allocation_regions'], + 'allocation_sectors': validated['allocation_sectors'], + 'allocation_asset_class': validated['allocation_asset_class'] + } + + return self.db.insert(self.table_name, data, returning='symbol') + + def find_by_type(self, instrument_type: str) -> List[Dict]: + """Find all instruments of a specific type""" + sql = f"SELECT * FROM {self.table_name} WHERE instrument_type = :type ORDER BY symbol" + params = [{'name': 'type', 'value': {'stringValue': instrument_type}}] + return self.db.query(sql, params) + + def search(self, query: str) -> List[Dict]: + """Search instruments by symbol or name""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE LOWER(symbol) LIKE LOWER(:query) + OR LOWER(name) LIKE LOWER(:query) + ORDER BY symbol + LIMIT 20 + """ + params = [{'name': 'query', 'value': {'stringValue': f'%{query}%'}}] + return self.db.query(sql, params) + + +class Accounts(BaseModel): + """Accounts table operations""" + table_name = 'accounts' + + def find_by_user(self, clerk_user_id: str) -> List[Dict]: + """Find all accounts for a user""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + """ + params = [{'name': 'user_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query(sql, params) + + def create_account(self, clerk_user_id: str, account_name: str, + account_purpose: str = None, cash_balance: Decimal = Decimal('0'), + cash_interest: Decimal = Decimal('0')) -> str: + """Create a new account""" + data = { + 'clerk_user_id': clerk_user_id, + 'account_name': account_name, + 'account_purpose': account_purpose, + 'cash_balance': cash_balance, + 'cash_interest': cash_interest + } + return self.db.insert(self.table_name, data, returning='id') + + +class Positions(BaseModel): + """Positions table operations""" + table_name = 'positions' + + def find_by_account(self, account_id: str) -> List[Dict]: + """Find all positions in an account""" + sql = f""" + SELECT p.*, i.name as instrument_name, i.instrument_type, i.current_price + FROM {self.table_name} p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + ORDER BY p.symbol + """ + params = [{'name': 'account_id', 'value': {'stringValue': account_id}}] + return self.db.query(sql, params) + + def get_portfolio_value(self, account_id: str) -> Dict: + """Calculate total portfolio value using current prices from instruments table""" + sql = """ + SELECT + COUNT(DISTINCT p.symbol) as num_positions, + SUM(p.quantity * i.current_price) as total_value, + SUM(p.quantity) as total_shares + FROM positions p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}} + ] + result = self.db.query_one(sql, params) + if result: + return { + 'num_positions': result.get('num_positions', 0), + 'total_value': float(result.get('total_value', 0)) if result.get('total_value') else 0, + 'total_shares': float(result.get('total_shares', 0)) if result.get('total_shares') else 0 + } + return {'num_positions': 0, 'total_value': 0, 'total_shares': 0} + + def add_position(self, account_id: str, symbol: str, quantity: Decimal) -> str: + """Add or update a position""" + # Use UPSERT to handle existing positions + sql = """ + INSERT INTO positions (account_id, symbol, quantity, as_of_date) + VALUES (:account_id::uuid, :symbol, :quantity::numeric, :as_of_date::date) + ON CONFLICT (account_id, symbol) + DO UPDATE SET + quantity = EXCLUDED.quantity, + as_of_date = EXCLUDED.as_of_date, + updated_at = NOW() + RETURNING id + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}}, + {'name': 'symbol', 'value': {'stringValue': symbol}}, + {'name': 'quantity', 'value': {'stringValue': str(quantity)}}, + {'name': 'as_of_date', 'value': {'stringValue': date.today().isoformat()}} + ] + response = self.db.execute(sql, params) + if response.get('records'): + return response['records'][0][0].get('stringValue') + return None + + +class Jobs(BaseModel): + """Jobs table operations""" + table_name = 'jobs' + + def create_job(self, clerk_user_id: str, job_type: str, + request_payload: Dict = None) -> str: + """Create a new job""" + data = { + 'clerk_user_id': clerk_user_id, + 'job_type': job_type, + 'status': 'pending', + 'request_payload': request_payload + } + return self.db.insert(self.table_name, data, returning='id') + + def update_status(self, job_id: str, status: str, error_message: str = None) -> int: + """Update job status""" + data = {'status': status} + + if status == 'running': + data['started_at'] = datetime.utcnow() + elif status in ['completed', 'failed', 'max_tokens_exceeded']: + data['completed_at'] = datetime.utcnow() + + if error_message: + data['error_message'] = error_message + + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_report(self, job_id: str, report_payload: Dict) -> int: + """Update job with Reporter agent's analysis""" + data = {'report_payload': report_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_charts(self, job_id: str, charts_payload: Dict) -> int: + """Update job with Charter agent's visualization data""" + data = {'charts_payload': charts_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_retirement(self, job_id: str, retirement_payload: Dict) -> int: + """Update job with Retirement agent's projections""" + data = {'retirement_payload': retirement_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_summary(self, job_id: str, summary_payload: Dict) -> int: + """Update job with Planner's final summary""" + data = {'summary_payload': summary_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def find_by_user(self, clerk_user_id: str, status: str = None, + limit: int = 20) -> List[Dict]: + """Find jobs for a user""" + if status: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id AND status = :status + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'status', 'value': {'stringValue': status}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + else: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + + return self.db.query(sql, params) + + +class Database: + """Main database interface providing access to all models""" + + def __init__(self, cluster_arn: str = None, secret_arn: str = None, + database: str = None, region: str = None): + """Initialize database with all model classes""" + self.client = DataAPIClient(cluster_arn, secret_arn, database, region) + + # Initialize all models + self.users = Users(self.client) + self.instruments = Instruments(self.client) + self.accounts = Accounts(self.client) + self.positions = Positions(self.client) + self.jobs = Jobs(self.client) + + def execute_raw(self, sql: str, parameters: List[Dict] = None) -> Dict: + """Execute raw SQL for complex queries""" + return self.client.execute(sql, parameters) + + def query_raw(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """Execute raw SELECT query""" + return self.client.query(sql, parameters) \ No newline at end of file diff --git a/backend/agent_reporter/src/schemas.py b/backend/agent_reporter/src/schemas.py new file mode 100644 index 00000000..44f16514 --- /dev/null +++ b/backend/agent_reporter/src/schemas.py @@ -0,0 +1,284 @@ +""" +Pydantic schemas for data validation and LLM tool interfaces +These models serve as both database validation and LLM structured output schemas +""" + +from typing import Dict, Literal, Optional, List +from pydantic import BaseModel, Field, field_validator +from decimal import Decimal +from datetime import date, datetime + + +# Define allowed values as Literals for LLM compatibility +RegionType = Literal[ + "north_america", + "europe", + "asia", + "latin_america", + "africa", + "middle_east", + "oceania", + "global", + "international", # For mixed non-US +] + +AssetClassType = Literal[ + "equity", "fixed_income", "real_estate", "commodities", "cash", "alternatives" +] + +SectorType = Literal[ + "technology", + "healthcare", + "financials", + "consumer_discretionary", + "consumer_staples", + "industrials", + "energy", + "materials", + "utilities", + "real_estate", + "communication", + "treasury", + "corporate", + "mortgage", + "government_related", + "commodities", + "diversified", + "other", +] + +InstrumentType = Literal["etf", "mutual_fund", "stock", "bond", "bond_fund", "commodity", "reit"] + +JobType = Literal[ + "portfolio_analysis", + "rebalance_recommendation", + "retirement_projection", + "risk_assessment", + "tax_optimization", + "instrument_research", +] + +JobStatus = Literal["pending", "running", "completed", "failed", "max_tokens_exceeded"] + +AccountType = Literal[ + "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" +] + + +class AllocationDict(BaseModel): + """Base class for allocation dictionaries ensuring they sum to 100""" + + @field_validator("*", mode="after") + def validate_sum(cls, v, info): + """Ensure allocation percentages sum to 100""" + if isinstance(v, dict): + total = sum(v.values()) + if abs(total - 100) > 3: # Allow small floating point errors + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class RegionAllocation(BaseModel): + """Geographic allocation of an instrument""" + + allocations: Dict[RegionType, float] = Field( + description="Percentage allocation by geographic region. Must sum to 100.", + example={"north_america": 60, "europe": 25, "asia": 15}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Region allocations must sum to 100, got {total}") + return v + + +class AssetClassAllocation(BaseModel): + """Asset class allocation of an instrument""" + + allocations: Dict[AssetClassType, float] = Field( + description="Percentage allocation by asset class. Must sum to 100.", + example={"equity": 80, "fixed_income": 20}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Asset class allocations must sum to 100, got {total}") + return v + + +class SectorAllocation(BaseModel): + """Sector allocation of an instrument""" + + allocations: Dict[SectorType, float] = Field( + description="Percentage allocation by market sector. Must sum to 100.", + example={"technology": 30, "healthcare": 25, "financials": 20, "other": 25}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Sector allocations must sum to 100, got {total}") + return v + + +class InstrumentCreate(BaseModel): + """Schema for creating a new instrument - suitable for LLM tool input""" + + symbol: str = Field( + description="The ticker symbol of the instrument (e.g., 'SPY', 'BND')", + min_length=1, + max_length=20, + ) + name: str = Field(description="Full name of the instrument", min_length=1, max_length=255) + instrument_type: InstrumentType = Field(description="The type of financial instrument") + current_price: Optional[Decimal] = Field( + None, + description="Current price of the instrument for portfolio calculations", + ge=0, + le=999999, + ) + allocation_regions: Dict[RegionType, float] = Field( + description="Geographic allocation percentages. Must sum to 100.", + example={"north_america": 100}, + ) + allocation_sectors: Dict[SectorType, float] = Field( + description="Sector allocation percentages. Must sum to 100.", + example={"technology": 40, "healthcare": 30, "financials": 30}, + ) + allocation_asset_class: Dict[AssetClassType, float] = Field( + description="Asset class allocation percentages. Must sum to 100.", example={"equity": 100} + ) + + @field_validator("allocation_regions", "allocation_sectors", "allocation_asset_class") + def validate_allocations(cls, v): + """Ensure all allocations sum to 100""" + if not v: + raise ValueError("Allocation cannot be empty") + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class InstrumentResponse(InstrumentCreate): + """Schema for instrument responses from database""" + + created_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + """Schema for creating a user - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="Unique identifier from Clerk authentication system") + display_name: Optional[str] = Field(None, description="User's display name", max_length=255) + years_until_retirement: Optional[int] = Field( + None, description="Number of years until the user plans to retire", ge=0, le=100 + ) + target_retirement_income: Optional[Decimal] = Field( + None, description="Annual income goal in retirement (in dollars)", ge=0, decimal_places=2 + ) + asset_class_targets: Optional[Dict[AssetClassType, float]] = Field( + default={"equity": 70, "fixed_income": 30}, + description="Target allocation percentages for rebalancing. Must sum to 100.", + ) + region_targets: Optional[Dict[RegionType, float]] = Field( + default={"north_america": 50, "international": 50}, + description="Target geographic allocation for rebalancing. Must sum to 100.", + ) + + +class AccountCreate(BaseModel): + """Schema for creating an account - suitable for LLM tool input""" + + account_name: str = Field( + description="Name of the account (e.g., '401k', 'Roth IRA')", min_length=1, max_length=255 + ) + account_purpose: Optional[str] = Field(None, description="Purpose or goal of this account") + cash_balance: Decimal = Field( + default=Decimal("0"), + description="Uninvested cash balance in the account", + ge=0, + decimal_places=2, + ) + cash_interest: Decimal = Field( + default=Decimal("0"), + description="Annual interest rate on cash (e.g., 0.045 for 4.5%)", + ge=0, + le=1, + decimal_places=4, + ) + + +class PositionCreate(BaseModel): + """Schema for creating a position - suitable for LLM tool input""" + + account_id: str = Field(description="UUID of the account holding this position") + symbol: str = Field(description="Ticker symbol of the instrument", min_length=1, max_length=20) + quantity: Decimal = Field( + description="Number of shares (supports fractional shares)", gt=0, decimal_places=8 + ) + as_of_date: Optional[date] = Field( + default_factory=date.today, description="Date of this position snapshot" + ) + + +class JobCreate(BaseModel): + """Schema for creating a job - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="User requesting this job") + job_type: JobType = Field(description="Type of analysis or operation to perform") + request_payload: Optional[Dict] = Field(None, description="Input parameters for the job") + + +class JobUpdate(BaseModel): + """Schema for updating job status - suitable for LLM tool output""" + + status: JobStatus = Field(description="Current status of the job") + result_payload: Optional[Dict] = Field(None, description="Results of the completed job") + error_message: Optional[str] = Field(None, description="Error details if job failed") + + +class PortfolioAnalysis(BaseModel): + """Schema for portfolio analysis results - LLM structured output""" + + total_value: Decimal = Field(description="Total portfolio value in dollars", decimal_places=2) + asset_allocation: Dict[AssetClassType, float] = Field( + description="Current asset class allocation percentages" + ) + region_allocation: Dict[RegionType, float] = Field( + description="Current geographic allocation percentages" + ) + sector_allocation: Dict[SectorType, float] = Field( + description="Current sector allocation percentages" + ) + risk_score: int = Field( + description="Risk score from 1 (conservative) to 10 (aggressive)", ge=1, le=10 + ) + recommendations: List[str] = Field( + description="List of actionable recommendations for the portfolio" + ) + + +class RebalanceRecommendation(BaseModel): + """Schema for rebalancing recommendations - LLM structured output""" + + current_allocation: Dict[str, float] = Field( + description="Current allocation by instrument symbol" + ) + target_allocation: Dict[str, float] = Field( + description="Recommended target allocation by symbol" + ) + trades: List[Dict] = Field( + description="List of trades needed to rebalance", + example=[ + {"symbol": "SPY", "action": "sell", "quantity": 10}, + {"symbol": "BND", "action": "buy", "quantity": 50}, + ], + ) + rationale: str = Field(description="Explanation of why these changes are recommended") diff --git a/backend/agent_reporter/test_full.py b/backend/agent_reporter/test_full.py new file mode 100644 index 00000000..ae4300c9 --- /dev/null +++ b/backend/agent_reporter/test_full.py @@ -0,0 +1,144 @@ +""" +Full test for the agent_reporter with actual Bedrock calls +""" + +import os +import json +import asyncio +import uuid +from dotenv import load_dotenv + +load_dotenv(override=True) + +async def test_full(): + """Test the reporter agent with actual Bedrock calls""" + + # Import database to create a real job + from src import Database + from src.schemas import JobCreate + + # Create a real user and job in the database + db = Database() + + # Create test user first + test_user_id = "test_user_full_001" + try: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User Full", + years_until_retirement=25, + target_retirement_income=75000 + ) + print(f"Created test user: {test_user_id}") + except Exception as e: + print(f"User might already exist: {e}") + + # Create test job + job_create = JobCreate( + clerk_user_id=test_user_id, + job_type="portfolio_analysis", + request_payload={"test": True} + ) + test_job_id = db.jobs.create(job_create.model_dump()) + print(f"Created test job in database: {test_job_id}") + + # Test payload with realistic data + payload = { + "job_id": test_job_id, + "portfolio_data": { + "accounts": [ + { + "name": "Investment Account", + "cash_balance": 10000, + "positions": [ + { + "symbol": "SPY", + "quantity": 50, + "instrument": { + "name": "SPDR S&P 500 ETF", + "current_price": 450, + "allocation_asset_class": {"equity": 100.0}, + "allocation_regions": {"north_america": 100.0}, + "allocation_sectors": { + "technology": 28.5, + "healthcare": 14.2, + "financials": 13.1, + "consumer_discretionary": 10.8, + "other": 33.4 + } + }, + }, + { + "symbol": "BND", + "quantity": 100, + "instrument": { + "name": "Vanguard Total Bond Market ETF", + "current_price": 85, + "allocation_asset_class": {"fixed_income": 100.0}, + "allocation_regions": {"north_america": 100.0}, + "allocation_sectors": { + "treasury": 40.0, + "corporate": 35.0, + "mortgage": 25.0 + } + }, + } + ], + } + ] + }, + "user_data": { + "years_until_retirement": 25, + "target_retirement_income": 75000 + }, + } + + try: + # Import and test + from agent import process_portfolio_report + + print("🚀 Running full reporter agent test...") + print(f"Portfolio value: ${payload['portfolio_data']['accounts'][0]['cash_balance'] + (50*450) + (100*85):,}") + + result = await process_portfolio_report( + payload["job_id"], + payload["portfolio_data"], + payload["user_data"] + ) + + print("\n" + "="*50) + print("RESULT:") + print("="*50) + print(json.dumps(result, indent=2)) + + if result.get("success"): + print("\n" + "="*50) + print("GENERATED REPORT:") + print("="*50) + print(result.get("final_output", "No output")) + print("✅ Full test completed successfully!") + else: + print("❌ Test failed:", result.get("error")) + + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + + finally: + # Clean up - delete the test job and user + try: + db.jobs.delete(test_job_id) + print(f"\n🧹 Deleted test job: {test_job_id}") + except Exception as e: + print(f"⚠️ Failed to delete test job: {e}") + + try: + # Delete user using clerk_user_id + db.client.delete("users", "clerk_user_id = :clerk_id", {"clerk_id": test_user_id}) + print(f"🧹 Deleted test user: {test_user_id}") + except Exception as e: + print(f"⚠️ Failed to delete test user: {e}") + +if __name__ == "__main__": + asyncio.run(test_full()) \ No newline at end of file diff --git a/backend/agent_reporter/test_simple.py b/backend/agent_reporter/test_simple.py new file mode 100644 index 00000000..cb53f12a --- /dev/null +++ b/backend/agent_reporter/test_simple.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Simple test for Agent Reporter +""" + +import json +from dotenv import load_dotenv + +load_dotenv(override=True) + +from src import Database +from src.schemas import JobCreate +from agent import reporter_agent + +def test_reporter(): + """Test the agent reporter with simple portfolio data""" + + # Create a real job in the database + db = Database() + job_create = JobCreate( + clerk_user_id="test_user_001", + job_type="portfolio_analysis", + request_payload={"test": True} + ) + job_id = db.jobs.create(job_create.model_dump()) + print(f"Created test job: {job_id}") + + test_payload = { + "job_id": job_id, + "portfolio_data": { + "accounts": [ + { + "name": "401(k)", + "cash_balance": 5000, + "positions": [ + { + "symbol": "SPY", + "quantity": 100, + "instrument": { + "name": "SPDR S&P 500 ETF", + "current_price": 450, + "allocation_asset_class": {"equity": 100.0}, + "allocation_regions": {"north_america": 100.0}, + "allocation_sectors": {"technology": 30.0, "healthcare": 15.0, "other": 55.0} + } + } + ] + } + ] + }, + "user_data": { + "years_until_retirement": 25, + "target_retirement_income": 75000 + } + } + + print("Testing Agent Reporter...") + print("=" * 60) + + result = reporter_agent(test_payload) + + print(f"Status Code: {result['statusCode']}") + + if result['statusCode'] == 200: + body = json.loads(result['body']) + print(f"Success: {body.get('success', False)}") + print(f"Message: {body.get('message', 'N/A')}") + + # Check what was actually saved in the database + print("\n" + "=" * 60) + print("CHECKING DATABASE CONTENT") + print("=" * 60) + + job = db.jobs.find_by_id(job_id) + if job and job.get('report_payload'): + payload = job['report_payload'] + print(f"✅ Report data found in database") + print(f"Payload keys: {list(payload.keys())}") + + if 'content' in payload: + content = payload['content'] + print(f"\nContent type: {type(content).__name__}") + + if isinstance(content, str): + print(f"Report length: {len(content)} characters") + + # Check if it contains reasoning artifacts + reasoning_indicators = [ + "I need to", + "I will", + "Let me", + "First,", + "I should", + "I'll", + "Now I", + "Next,", + ] + + contains_reasoning = any(indicator.lower() in content.lower() for indicator in reasoning_indicators) + + if contains_reasoning: + print("⚠️ WARNING: Report may contain reasoning/thinking text") + else: + print("✅ Report appears to be final output only (no reasoning detected)") + + # Show first 500 characters and last 200 characters + print(f"\nFirst 500 characters:") + print("-" * 40) + print(content[:500]) + print("-" * 40) + + if len(content) > 700: + print(f"\nLast 200 characters:") + print("-" * 40) + print(content[-200:]) + print("-" * 40) + else: + print(f"⚠️ Content is not a string: {type(content)}") + print(f"Content: {str(content)[:200]}") + + print(f"\nGenerated at: {payload.get('generated_at', 'N/A')}") + print(f"Agent: {payload.get('agent', 'N/A')}") + else: + print("❌ No report data found in database") + else: + print(f"Error: {result['body']}") + + # Clean up - delete the test job + db.jobs.delete(job_id) + print(f"\nDeleted test job: {job_id}") + + print("=" * 60) + +if __name__ == "__main__": + test_reporter() \ No newline at end of file diff --git a/backend/agent_reporter/utils.py b/backend/agent_reporter/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/agent_reporter/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/agent_researcher/.gitignore b/backend/agent_researcher/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_researcher/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_researcher/agent.py b/backend/agent_researcher/agent.py new file mode 100644 index 00000000..0a87f23a --- /dev/null +++ b/backend/agent_researcher/agent.py @@ -0,0 +1,179 @@ +""" +Investment Researcher Agent using Bedrock AgentCore with real browser functionality. + +This agent researches current investment topics by browsing financial websites +and provides concise analysis stored in the knowledge base. +""" + +import os +import logging +from datetime import datetime, UTC +from typing import Dict, Any, Optional +import httpx +from tenacity import retry, stop_after_attempt, wait_exponential + +# Load environment variables from SSM at startup +import sys +sys.path.append('/opt/python') # Add common layer path if available +try: + from utils import load_env_from_ssm + load_env_from_ssm() + print("✅ Loaded environment variables from SSM") +except Exception as e: + print(f"⚠️ Could not load environment from SSM: {e}") + # Fallback to local .env file + try: + from dotenv import load_dotenv + load_dotenv() + print("✅ Loaded environment variables from .env file") + except ImportError: + print("⚠️ python-dotenv not available, skipping .env file loading") + except Exception as e2: + print(f"⚠️ Could not load .env file: {e2}") + +from strands import Agent, tool +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +from strands_tools.browser import AgentCoreBrowser +from tools import ingest_financial_document + +logger = logging.getLogger(__name__) + +# Configuration from environment +ALEX_API_ENDPOINT = os.getenv("ALEX_API_ENDPOINT") +ALEX_API_KEY = os.getenv("ALEX_API_KEY") + +def get_agent_instructions(): + """Get agent instructions with current date.""" + today = datetime.now().strftime("%B %d, %Y") + + return f"""You are Alex, a concise investment researcher and financial analyst. Today is {today}. + +CRITICAL: Work quickly and efficiently. You have limited time. + +You are an intelligent financial analyst that specializes in analyzing stock and financial websites. When asked to analyze a financial website: + +1. Use the browser tool to visit and interact with the website EFFICIENTLY +2. Focus on extracting key financial information QUICKLY: + +**For Financial/Stock Websites (MarketWatch, Bloomberg, Yahoo Finance, etc.):** +- Current stock prices and market data +- Price movements and trends (daily, weekly, monthly changes) +- Key financial metrics and ratios (P/E, Market Cap, etc.) +- Trading volume and market activity +- Recent news and market sentiment +- Analyst recommendations and price targets +- Company fundamentals and performance indicators + +Your THREE steps (BE CONCISE): + +1. WEB RESEARCH (1-2 pages MAX): + - Use browser to navigate to ONE main source (Yahoo Finance, MarketWatch, or Bloomberg) + - Extract key financial data efficiently + - If needed, visit ONE more page for verification + - DO NOT browse extensively - 2 pages maximum + +2. BRIEF ANALYSIS (Keep it short): + - Key facts and numbers only + - 3-5 bullet points maximum + - One clear recommendation + - Be extremely concise + +3. SAVE TO DATABASE: + - Use ingest_financial_document immediately + - Topic: "[Asset] Analysis {{datetime.now().strftime('%b %d')}}" + - Save your brief analysis + +SPEED IS CRITICAL: +- Maximum 2 web pages +- Brief, bullet-point analysis +- No lengthy explanations +- Work as quickly as possible +- Always provide specific, actionable financial insights with actual numbers and data points +""" + +DEFAULT_RESEARCH_PROMPT = """Please research a current, interesting investment topic from today's financial news. +Pick something trending or significant happening in the markets right now. +Follow all three steps: search, analyze, and store your findings.""" + +def create_agent_and_run(topic: Optional[str] = None) -> str: + """ + Create and run the researcher agent to generate investment analysis. + + Args: + topic: Optional specific topic to research. If None, agent picks current trending topic. + + Returns: + Research analysis and recommendations + """ + logger.info(f"Researcher Agent: Starting research for topic: {topic or 'agent choice'}") + + # Initialize browser and model + region = os.getenv("BEDROCK_REGION", "us-west-2") + model_id = os.getenv("BEDROCK_MODEL_ID", "anthropic.claude-3-haiku-20240307-v1:0") + + logger.info(f"Researcher Agent: Using model {model_id} in region {region}") + + # Create browser tool + agent_core_browser = AgentCoreBrowser(region=region) + + # Create agent with Claude Haiku model and browser tool + agent = Agent( + name="Alex Investment Researcher", + system_prompt=get_agent_instructions(), + model=model_id, + tools=[agent_core_browser.browser, ingest_financial_document] + ) + + # Prepare the query + if topic: + query = f"Research this investment topic: {topic}. Use the browser to visit financial websites like Yahoo Finance, MarketWatch, or Bloomberg to gather current data and analysis." + else: + query = DEFAULT_RESEARCH_PROMPT + " Use the browser to visit financial websites to find trending topics and gather current market data." + + logger.info(f"Researcher Agent: Query prepared: {query[:100]}...") + + # Run agent + try: + response = agent(query) + + # Extract text from AgentResult if needed + if hasattr(response, 'text'): + response_text = response.text + else: + response_text = str(response) + + logger.info(f"Researcher Agent: Generated response, length: {len(response_text) if response_text else 0}") + return response_text + + except Exception as e: + logger.error(f"Researcher Agent: Error during execution: {e}") + return f"Research agent failed: {str(e)}" + +# Bedrock AgentCore entry point +def agent(): + """Entry point for Bedrock AgentCore runtime.""" + app = BedrockAgentCoreApp() + + @app.agent() + def researcher_agent(event): + """Investment Researcher Agent handler.""" + logger.info(f"Researcher Agent: Received event with keys: {list(event.keys()) if isinstance(event, dict) else 'not a dict'}") + + # Extract topic if provided + topic = event.get('topic') + + # Run the agent + result = create_agent_and_run(topic) + return result + + return app + +if __name__ == "__main__": + # Test the agent locally + result = create_agent_and_run() + print("Researcher Agent Result:") + print("=" * 50) + print(result) + print("=" * 50) \ No newline at end of file diff --git a/backend/agent_researcher/requirements.txt b/backend/agent_researcher/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_researcher/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_researcher/screenshots/screenshot_1761062436.png b/backend/agent_researcher/screenshots/screenshot_1761062436.png new file mode 100644 index 00000000..f5b42998 Binary files /dev/null and b/backend/agent_researcher/screenshots/screenshot_1761062436.png differ diff --git a/backend/agent_researcher/screenshots/tesla_screenshot.png b/backend/agent_researcher/screenshots/tesla_screenshot.png new file mode 100644 index 00000000..5139ce9a Binary files /dev/null and b/backend/agent_researcher/screenshots/tesla_screenshot.png differ diff --git a/backend/agent_researcher/test_full.py b/backend/agent_researcher/test_full.py new file mode 100644 index 00000000..294f261d --- /dev/null +++ b/backend/agent_researcher/test_full.py @@ -0,0 +1,219 @@ +""" +Test the Researcher Agent with comprehensive scenarios (full test). +""" + +import os +from agent import create_agent_and_run + +def test_specific_topics(): + """Test the researcher agent with various specific investment topics.""" + + topics = [ + "Bitcoin ETF Analysis", + "AI Semiconductor Stocks", + "Green Energy Investment Trends", + "Real Estate Market Outlook", + "Banking Sector Analysis" + ] + + results = {} + + print("🔍 Testing Researcher Agent with Multiple Topics...") + print(f"📊 Testing {len(topics)} different investment topics") + + for i, topic in enumerate(topics, 1): + print(f"\n📈 Test {i}/{len(topics)}: {topic}") + print("-" * 40) + + try: + result = create_agent_and_run(topic) + results[topic] = { + "success": True, + "result": result, + "length": len(result) if result else 0 + } + + print(f"✅ Research completed for {topic}") + print(f"📏 Response length: {len(result)} characters") + + # Show preview of result + preview = result[:200] + "..." if len(result) > 200 else result + print(f"📄 Preview: {preview}") + + except Exception as e: + print(f"❌ Error researching {topic}: {e}") + results[topic] = { + "success": False, + "error": str(e), + "length": 0 + } + + # Summary + print("\n" + "=" * 60) + print("📊 RESEARCHER AGENT TEST SUMMARY") + print("=" * 60) + + successful_tests = [topic for topic, result in results.items() if result["success"]] + failed_tests = [topic for topic, result in results.items() if not result["success"]] + + print(f"✅ Successful: {len(successful_tests)}/{len(topics)}") + print(f"❌ Failed: {len(failed_tests)}/{len(topics)}") + + if successful_tests: + print(f"\n🎯 Successful Topics:") + for topic in successful_tests: + length = results[topic]["length"] + print(f" - {topic}: {length} chars") + + if failed_tests: + print(f"\n💥 Failed Topics:") + for topic in failed_tests: + error = results[topic]["error"] + print(f" - {topic}: {error}") + + # Analysis + total_chars = sum(result["length"] for result in results.values() if result["success"]) + avg_length = total_chars / len(successful_tests) if successful_tests else 0 + + print(f"\n📈 Analysis:") + print(f" Total characters generated: {total_chars:,}") + print(f" Average response length: {avg_length:.0f} chars") + print(f" Success rate: {len(successful_tests)/len(topics)*100:.1f}%") + + return results + +def test_api_configuration(): + """Test different API configuration scenarios.""" + print("\n🔧 Testing API Configuration Scenarios...") + + # Save original values + orig_endpoint = os.environ.get("ALEX_API_ENDPOINT") + orig_key = os.environ.get("ALEX_API_KEY") + + scenarios = [ + { + "name": "No API Configuration", + "endpoint": None, + "key": None, + "expected": "Should note local mode" + }, + { + "name": "Partial Configuration", + "endpoint": "https://example.com/api", + "key": None, + "expected": "Should handle missing key" + }, + { + "name": "Mock Full Configuration", + "endpoint": "https://mock-api.alex.com/ingest", + "key": "mock-api-key-123", + "expected": "Should attempt ingestion (may fail)" + } + ] + + for i, scenario in enumerate(scenarios, 1): + print(f"\n🧪 Scenario {i}: {scenario['name']}") + print(f"Expected: {scenario['expected']}") + + # Set environment + if scenario["endpoint"]: + os.environ["ALEX_API_ENDPOINT"] = scenario["endpoint"] + elif "ALEX_API_ENDPOINT" in os.environ: + del os.environ["ALEX_API_ENDPOINT"] + + if scenario["key"]: + os.environ["ALEX_API_KEY"] = scenario["key"] + elif "ALEX_API_KEY" in os.environ: + del os.environ["ALEX_API_KEY"] + + try: + result = create_agent_and_run("Quick API Test Topic") + print(f"✅ Scenario completed") + + # Check if result mentions API issues + result_lower = result.lower() + if "api" in result_lower or "local" in result_lower or "config" in result_lower: + print(f"📝 API-related content detected in response") + + except Exception as e: + print(f"❌ Scenario failed: {e}") + + # Restore original values + if orig_endpoint: + os.environ["ALEX_API_ENDPOINT"] = orig_endpoint + elif "ALEX_API_ENDPOINT" in os.environ: + del os.environ["ALEX_API_ENDPOINT"] + + if orig_key: + os.environ["ALEX_API_KEY"] = orig_key + elif "ALEX_API_KEY" in os.environ: + del os.environ["ALEX_API_KEY"] + +def test_edge_cases(): + """Test edge cases and error handling.""" + print("\n🔬 Testing Edge Cases...") + + edge_cases = [ + { + "name": "Empty Topic", + "topic": "", + "description": "Test with empty string topic" + }, + { + "name": "Very Long Topic", + "topic": "A" * 500, # 500 character topic + "description": "Test with extremely long topic" + }, + { + "name": "Special Characters", + "topic": "Tesla Stock: P/E, ROI & Market Cap Analysis! @#$%", + "description": "Test with special characters" + }, + { + "name": "Non-English Topic", + "topic": "テスラ株式分析", # Tesla stock analysis in Japanese + "description": "Test with non-English characters" + } + ] + + for i, case in enumerate(edge_cases, 1): + print(f"\n🧪 Edge Case {i}: {case['name']}") + print(f"Description: {case['description']}") + print(f"Topic: {case['topic'][:100]}{'...' if len(case['topic']) > 100 else ''}") + + try: + result = create_agent_and_run(case["topic"]) + print(f"✅ Edge case handled successfully") + print(f"📏 Response length: {len(result)} characters") + + except Exception as e: + print(f"❌ Edge case failed: {e}") + +def main(): + """Run all full tests.""" + print("🔍 RESEARCHER AGENT FULL TEST SUITE") + print("=" * 60) + + # Set up environment + os.environ.setdefault("BEDROCK_MODEL_ID", "us.amazon.nova-pro-v1:0") + os.environ.setdefault("BEDROCK_REGION", "us-west-2") + + try: + # Run all test suites + results = test_specific_topics() + test_api_configuration() + test_edge_cases() + + print("\n" + "=" * 60) + print("🎯 FULL TEST SUITE COMPLETED") + print("=" * 60) + print("✅ All test categories executed") + print("📊 Check individual results above for detailed analysis") + + except Exception as e: + print(f"❌ Full test suite failed: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/agent_researcher/test_simple.py b/backend/agent_researcher/test_simple.py new file mode 100644 index 00000000..cd47050c --- /dev/null +++ b/backend/agent_researcher/test_simple.py @@ -0,0 +1,223 @@ +""" +Test the Researcher Agent with browser tools (simple test). +Tests saving to database, verification, and proper cleanup. +""" + +import os +import sys +import json +from datetime import datetime +from dotenv import load_dotenv + +# Load environment variables +load_dotenv(override=True) + +# Add the database path to import the database modules +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'database')) + +from src.client import DataAPIClient +from src.models import Database +from src.schemas import JobCreate +from agent import create_agent_and_run + +def test_researcher_agent_with_database(): + """Test the researcher agent and save results to database.""" + print("🔍 Testing Researcher Agent with Database Integration...") + + # Initialize database + try: + db = DataAPIClient() + db_models = Database() + print(f"🎯 Using {db.db_backend.upper()} backend") + except Exception as e: + print(f"❌ Failed to initialize database: {e}") + return False + + # Create a test job + test_job = None + try: + job_create = JobCreate( + clerk_user_id="test_user_001", + job_type="instrument_research", + request_payload={"topic": "Tesla Stock Analysis", "test": True} + ) + job_id = db_models.jobs.create(job_create.model_dump()) + print(f"✅ Created test job: {job_id}") + test_job = job_id + except Exception as e: + print(f"❌ Failed to create test job: {e}") + return False + + # Test with a specific topic + topic = "Tesla Stock Analysis" + print(f"📊 Research Topic: {topic}") + + try: + # Set up environment variables for testing + os.environ.setdefault("BEDROCK_MODEL_ID", "anthropic.claude-3-haiku-20240307-v1:0") + os.environ.setdefault("BEDROCK_REGION", "us-west-2") + + print(f"🌐 Using model: {os.environ.get('BEDROCK_MODEL_ID')}") + print(f"🌍 Using region: {os.environ.get('BEDROCK_REGION')}") + + print("\n🔍 Running Researcher Agent...") + + # Add timeout to prevent hanging + import signal + + def timeout_handler(signum, frame): + raise TimeoutError("Researcher agent execution timed out") + + # Set a 5-minute timeout + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(300) # 5 minutes + + try: + result = create_agent_and_run(topic) + finally: + signal.alarm(0) # Cancel the alarm + + if not result: + print("❌ No result from researcher agent") + return False + + print(f"📊 Research completed, result length: {len(result)} characters") + + # Save result to database in report_payload + try: + report_payload = { + "content": result, + "topic": topic, + "agent": "researcher", + "generated_at": datetime.now().isoformat() + } + + # Update the job with the research report + db_models.jobs.update_report(test_job, report_payload) + print("✅ Saved research report to database") + + except Exception as e: + print(f"❌ Failed to save report to database: {e}") + return False + + # Verify the record was saved correctly + try: + saved_job = db_models.jobs.find_by_id(test_job) + if saved_job and saved_job.get('report_payload'): + payload = saved_job['report_payload'] + print("✅ Verified report saved in database") + print(f" Report content length: {len(payload.get('content', ''))}") + print(f" Topic: {payload.get('topic')}") + print(f" Agent: {payload.get('agent')}") + print(f" Generated at: {payload.get('generated_at')}") + + # Show snippet of content + content = payload.get('content', '') + if content: + snippet = content[:200] + "..." if len(content) > 200 else content + print(f" Content snippet: {snippet}") + + else: + print("❌ Report not found in database") + return False + + except Exception as e: + print(f"❌ Failed to verify database record: {e}") + return False + + # Basic validation of the research content + if result and len(result) > 50: + print("✅ Researcher Agent generated substantial output") + else: + print("⚠️ Researcher Agent output seems short") + + if "Tesla" in result or "TSLA" in result: + print("✅ Response appears to be about the requested topic") + else: + print("⚠️ Response may not be about the requested topic") + + return True + + except Exception as e: + print(f"❌ Error during Researcher Agent test: {e}") + import traceback + traceback.print_exc() + return False + + finally: + # Clean up - delete the test job + if test_job: + try: + db_models.jobs.delete(test_job) + print(f"✅ Deleted test job: {test_job}") + except Exception as e: + print(f"⚠️ Failed to delete test job {test_job}: {e}") + +def test_researcher_agent(): + """Test the researcher agent with a specific topic.""" + print("🔍 Testing Researcher Agent with Browser...") + + # Test with a specific topic + topic = "Tesla Stock Analysis" + print(f"📊 Research Topic: {topic}") + + try: + # Set up environment variables for testing + os.environ.setdefault("BEDROCK_MODEL_ID", "anthropic.claude-3-haiku-20240307-v1:0") + os.environ.setdefault("BEDROCK_REGION", "us-west-2") + + # Note: ALEX_API_ENDPOINT and ALEX_API_KEY should be set for document ingestion + # If not set, the agent will note this in local mode + + print(f"🌐 Using model: {os.environ.get('BEDROCK_MODEL_ID')}") + print(f"🌍 Using region: {os.environ.get('BEDROCK_REGION')}") + + print("\n🔍 Running Researcher Agent with Browser...") + print(" The agent will use real browser automation to visit financial websites") + result = create_agent_and_run(topic) + + print("📊 Researcher Agent Result:") + print("=" * 50) + print(result) + print("=" * 50) + + # Basic validation + if result and len(result) > 50: + print("✅ Researcher Agent generated substantial output") + else: + print("⚠️ Researcher Agent output seems short or empty") + + if "Tesla" in result or "TSLA" in result: + print("✅ Response appears to be about the requested topic") + else: + print("⚠️ Response may not be about the requested topic") + + # Check for browser activity indicators + result_lower = result.lower() + browser_indicators = ["website", "browser", "visited", "navigated", "page", "url"] + if any(indicator in result_lower for indicator in browser_indicators): + print("✅ Response indicates browser activity") + else: + print("⚠️ No clear evidence of browser usage in response") + + print("✅ Researcher Agent test completed") + + except Exception as e: + print(f"❌ Error during Researcher Agent test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("🚀 Starting Researcher Agent Tests") + print("=" * 60) + + # Run the database integration test + success = test_researcher_agent_with_database() + + print("\n" + "=" * 60) + if success: + print("✅ All tests completed successfully!") + sys.exit(0) + else: + print("❌ Test failed!") + sys.exit(1) \ No newline at end of file diff --git a/backend/agent_researcher/tools.py b/backend/agent_researcher/tools.py new file mode 100644 index 00000000..7c37ca8e --- /dev/null +++ b/backend/agent_researcher/tools.py @@ -0,0 +1,86 @@ +""" +Tools for the Alex Researcher agent using AgentCore +""" +import os +from typing import Dict, Any +from datetime import datetime, UTC +import httpx +from strands.tools import tool +from tenacity import retry, stop_after_attempt, wait_exponential +import logging + +logger = logging.getLogger(__name__) + + +def _ingest(document: Dict[str, Any], api_endpoint: str, api_key: str) -> Dict[str, Any]: + """Internal function to make the actual API call.""" + with httpx.Client() as client: + response = client.post( + api_endpoint, + json=document, + headers={"x-api-key": api_key}, + timeout=30.0 + ) + response.raise_for_status() + return response.json() + + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10) +) +def ingest_with_retries(document: Dict[str, Any], api_endpoint: str, api_key: str) -> Dict[str, Any]: + """Ingest with retry logic for SageMaker cold starts.""" + return _ingest(document, api_endpoint, api_key) + + +@tool +def ingest_financial_document(topic: str, analysis: str, alex_api_endpoint: str, alex_api_key: str) -> Dict[str, Any]: + """ + Ingest a financial document into the Alex knowledge base. + + Args: + topic: The topic or subject of the analysis (e.g., "AAPL Stock Analysis", "Retirement Planning Guide") + analysis: Detailed analysis or advice with specific data and insights + + Returns: + Dictionary with success status and document ID + """ + logger.info(f"Researcher: Ingesting document with topic: {topic}") + + # Read environment variables at runtime + alex_api_endpoint = os.getenv("ALEX_API_ENDPOINT") + alex_api_key = os.getenv("ALEX_API_KEY") + + logger.info(f"Researcher: API endpoint configured: {bool(alex_api_endpoint)}") + logger.info(f"Researcher: API key configured: {bool(alex_api_key)}") + + if not alex_api_endpoint or not alex_api_key: + logger.warning("Researcher: Alex API not configured, running in local mode") + return { + "success": False, + "error": "Alex API not configured. Running in local mode." + } + + document = { + "text": analysis, + "metadata": { + "topic": topic, + "timestamp": datetime.now(UTC).isoformat() + } + } + + try: + result = ingest_with_retries(document, alex_api_endpoint, alex_api_key) + logger.info(f"Researcher: Successfully ingested document: {topic}") + return { + "success": True, + "document_id": result.get("document_id"), + "message": f"Successfully ingested analysis for {topic}" + } + except Exception as e: + logger.error(f"Researcher: Failed to ingest document: {e}") + return { + "success": False, + "error": str(e) + } \ No newline at end of file diff --git a/backend/agent_retirement/.bedrock_agentcore.yaml b/backend/agent_retirement/.bedrock_agentcore.yaml new file mode 100644 index 00000000..075c03dd --- /dev/null +++ b/backend/agent_retirement/.bedrock_agentcore.yaml @@ -0,0 +1,41 @@ +default_agent: retirement +agents: + retirement: + name: retirement + entrypoint: /Users/fotis/Documents/CV/Learning/AI in production/alex/backend/agent_retirement/agent.py + platform: linux/arm64 + container_runtime: docker + source_path: null + aws: + execution_role: arn:aws:iam::717174128108:role/agentcore-retirement-role + execution_role_auto_create: false + account: '717174128108' + region: us-east-1 + ecr_repository: 717174128108.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-retirement + ecr_auto_create: false + network_configuration: + network_mode: PUBLIC + network_mode_config: null + protocol_configuration: + server_protocol: HTTP + observability: + enabled: true + bedrock_agentcore: + agent_id: retirement-BsweZS2cq9 + agent_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:runtime/retirement-BsweZS2cq9 + agent_session_id: null + codebuild: + project_name: bedrock-agentcore-retirement-builder + execution_role: arn:aws:iam::717174128108:role/AmazonBedrockAgentCoreSDKCodeBuild-us-east-1-0b80c840b3 + source_bucket: bedrock-agentcore-codebuild-sources-717174128108-us-east-1 + memory: + mode: STM_ONLY + memory_id: retirement_mem-nR0bT13qsR + memory_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:memory/retirement_mem-nR0bT13qsR + memory_name: retirement_mem + event_expiry_days: 30 + first_invoke_memory_check_done: false + was_created_by_toolkit: false + authorizer_configuration: null + request_header_configuration: null + oauth_configuration: null diff --git a/backend/agent_retirement/.dockerignore b/backend/agent_retirement/.dockerignore new file mode 100644 index 00000000..bf13996c --- /dev/null +++ b/backend/agent_retirement/.dockerignore @@ -0,0 +1,69 @@ +# Build artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Python cache +__pycache__/ +__pycache__* +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +.env +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +.coverage* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.mypy_cache/ +.ruff_cache/ + +# Development +*.log +*.bak +*.swp +*.swo +*~ +.DS_Store + +# IDEs +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Project specific +tests/ + +# Bedrock AgentCore specific - keep config but exclude runtime files +.bedrock_agentcore.yaml +.dockerignore +.bedrock_agentcore/ + +# Keep wheelhouse for offline installations +# wheelhouse/ diff --git a/backend/agent_retirement/.gitignore b/backend/agent_retirement/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_retirement/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_retirement/Dockerfile b/backend/agent_retirement/Dockerfile new file mode 100644 index 00000000..16664454 --- /dev/null +++ b/backend/agent_retirement/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app + +# All environment variables in one layer +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + AWS_REGION=us-east-1 \ + AWS_DEFAULT_REGION=us-east-1 \ + BEDROCK_AGENTCORE_MEMORY_ID=retirement_mem-nR0bT13qsR \ + BEDROCK_AGENTCORE_MEMORY_NAME=retirement_mem + + + +COPY requirements.txt requirements.txt +# Install from requirements file +RUN uv pip install -r requirements.txt + + + + +RUN uv pip install aws-opentelemetry-distro>=0.10.1 + + +# Signal that this is running in Docker for host binding logic +ENV DOCKER_CONTAINER=1 + +# Create non-root user +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 9000 +EXPOSE 8000 +EXPOSE 8080 + +# Copy entire project (respecting .dockerignore) +COPY . . + +# Use the full module path + +CMD ["opentelemetry-instrument", "python", "-m", "agent"] diff --git a/backend/agent_retirement/README.md b/backend/agent_retirement/README.md new file mode 100644 index 00000000..b813171e --- /dev/null +++ b/backend/agent_retirement/README.md @@ -0,0 +1,173 @@ +# Agent Retirement + +Bedrock AgentCore implementation of the retirement specialist agent that provides comprehensive retirement planning analysis and projections. + +## Overview + +This agent analyzes portfolio data and user retirement goals to provide detailed retirement readiness assessments, Monte Carlo simulation results, and actionable recommendations for retirement planning. + +## Features + +- **Retirement Readiness Analysis**: Comprehensive assessment of current retirement preparedness +- **Monte Carlo Simulations**: 500-scenario probabilistic analysis for retirement success +- **Asset Allocation Analysis**: Evaluation of portfolio allocation appropriateness for retirement timeline +- **Risk Assessment**: Analysis of sequence of returns, inflation, and longevity risks +- **Actionable Recommendations**: Specific, timeline-based advice to improve retirement outcomes +- **Database Integration**: Automatic saving of retirement analysis + +## Architecture + +Built using: +- **Strands**: Core agent framework +- **Bedrock AgentCore**: AWS Bedrock integration +- **BedrockModel**: Direct AWS Bedrock model access +- **Monte Carlo Engine**: Statistical retirement projections +- **Aurora Database**: Analysis storage and user data + +## Dependencies + +- `strands>=0.8.4`: Core agent framework +- `bedrock-agentcore>=1.0.0`: AWS Bedrock integration +- `alex-database`: Shared database library +- `boto3`: AWS SDK +- `pydantic`: Data validation +- `python-dotenv`: Environment configuration + +## Environment Variables + +Required environment variables: + +```bash +BEDROCK_MODEL_ID=us.anthropic.claude-3-7-sonnet-20250219-v1:0 +BEDROCK_REGION=us-west-2 +``` + +## Usage + +### As BedrockAgentCore App + +```python +from agent import app + +# Run as Bedrock AgentCore app +if __name__ == "__main__": + app.run() +``` + +### Direct Function Call + +```python +from agent import process_retirement_analysis + +payload = { + "job_id": "unique-job-id", + "portfolio_data": { + "accounts": [ + { + "name": "401(k)", + "type": "retirement", + "cash_balance": 10000, + "positions": [ + { + "symbol": "SPY", + "quantity": 100, + "instrument": { + "name": "SPDR S&P 500 ETF", + "current_price": 450, + "allocation_asset_class": {"equity": 100.0} + } + } + ] + } + ] + } +} + +result = await process_retirement_analysis( + payload["job_id"], + payload["portfolio_data"] +) +``` + +## Testing + +### Simple Test (with real database) + +```bash +uv run test_simple.py +``` + +### Full Test (with Bedrock) + +```bash +uv run test_full.py +``` + +## Analysis Components + +### Monte Carlo Simulation + +Runs 500 scenarios analyzing: +- **Accumulation Phase**: Portfolio growth until retirement +- **Distribution Phase**: 30-year retirement income sustainability +- **Risk Factors**: Market volatility, sequence of returns, inflation +- **Success Metrics**: Probability of maintaining target income + +### Key Metrics + +1. **Success Rate**: Percentage of scenarios sustaining 30-year retirement +2. **Expected Value at Retirement**: Mean portfolio value at retirement age +3. **Percentile Analysis**: 10th, 50th, and 90th percentile outcomes +4. **Years Portfolio Lasts**: Average duration of income sustainability + +### Risk Analysis + +- **Sequence of Returns Risk**: Poor early retirement returns impact +- **Inflation Impact**: 3% annual inflation adjustment +- **Longevity Risk**: Planning beyond 30-year retirement +- **Market Volatility**: Asset class return variability + +## Output Format + +Generated analysis includes: + +1. **Retirement Readiness Assessment**: Clear probability-based evaluation +2. **Monte Carlo Results**: Success rates and outcome distributions +3. **Asset Allocation Review**: Appropriateness for retirement timeline +4. **Risk Mitigation Strategies**: Specific recommendations for risk reduction +5. **Action Items**: Timeline-based recommendations for improvement +6. **Gap Analysis**: Difference between current trajectory and goals + +## Mathematical Models + +### Expected Returns +- **Equity**: 7% mean, 18% standard deviation +- **Bonds**: 4% mean, 5% standard deviation +- **Real Estate**: 6% mean, 12% standard deviation +- **Cash**: 2% fixed return + +### Assumptions +- **Annual Contributions**: $10,000 during accumulation +- **Withdrawal Rate**: 4% rule for retirement income +- **Inflation**: 3% annual adjustment +- **Retirement Duration**: 30 years + +## Error Handling + +- Comprehensive error handling and logging +- Database transaction safety +- Default values for missing user preferences +- Graceful handling of calculation edge cases + +## Integration + +This agent integrates with: + +- **Planner Agent**: Receives orchestration requests +- **Database**: Loads user preferences and saves analysis +- **User Management**: Clerk user ID integration +- **Aurora**: Analysis storage and retrieval + +## Deployment + +Deploy as part of the Alex agent orchestra using the terraform configuration in `terraform/6_agents/`. \ No newline at end of file diff --git a/backend/agent_retirement/agent.py b/backend/agent_retirement/agent.py new file mode 100644 index 00000000..227c7ee7 --- /dev/null +++ b/backend/agent_retirement/agent.py @@ -0,0 +1,532 @@ +""" +Retirement Specialist Agent - provides retirement planning analysis and projections using Bedrock AgentCore. +""" + +import os +import json +import logging +import asyncio +import random +from typing import Dict, Any +from datetime import datetime + +# Load environment variables from SSM at startup +import sys +sys.path.append('/opt/python') # Add common layer path if available +try: + from utils import load_env_from_ssm + load_env_from_ssm() + print("✅ Loaded environment variables from SSM") +except Exception as e: + print(f"⚠️ Could not load environment from SSM: {e}") + # Fallback to local .env file + try: + from dotenv import load_dotenv + load_dotenv() + print("✅ Loaded environment variables from .env file") + except ImportError: + print("⚠️ python-dotenv not available, skipping .env file loading") + except Exception as e2: + print(f"⚠️ Could not load .env file: {e2}") + +from strands import Agent +from strands.models import BedrockModel +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +# Add current directory to Python path for src imports +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'database'))) + +# Import database package +from src import Database + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Get configuration +model_id = os.getenv("BEDROCK_MODEL_ID", "us.anthropic.claude-3-7-sonnet-20250219-v1:0") +BEDROCK_REGION = os.getenv("BEDROCK_REGION", "us-west-2") + +db = Database() + +# Retirement instructions +RETIREMENT_INSTRUCTIONS = """You are a retirement planning specialist with expertise in portfolio analysis, Monte Carlo simulations, and retirement readiness assessments. + +Your task is to analyze portfolio data and user retirement goals to provide comprehensive retirement planning analysis and actionable recommendations. + +Key responsibilities: +1. Assess current retirement readiness based on portfolio value and goals +2. Analyze Monte Carlo simulation results to provide probability-based insights +3. Evaluate asset allocation for retirement timeline appropriateness +4. Provide specific, actionable recommendations to improve retirement outcomes +5. Address key risks like sequence of returns, inflation, and longevity + +Important guidelines: +- Use specific numbers and percentages in your analysis +- Provide clear success/failure probabilities based on simulations +- Give actionable recommendations with timelines +- Address risk mitigation strategies +- Use markdown formatting for clear structure +- Be realistic about retirement challenges while remaining constructive +""" + +def get_user_preferences(job_id: str) -> Dict[str, Any]: + """Load user preferences from database.""" + try: + # Get the job to find the user + job = db.jobs.find_by_id(job_id) + if job and job.get('clerk_user_id'): + # Get user preferences + user = db.users.find_by_clerk_id(job['clerk_user_id']) + if user: + return { + 'years_until_retirement': user.get('years_until_retirement', 30), + 'target_retirement_income': float(user.get('target_retirement_income', 80000)), + 'current_age': 40 # Default for now + } + except Exception as e: + logger.warning(f"Could not load user data: {e}. Using defaults.") + + return { + 'years_until_retirement': 30, + 'target_retirement_income': 80000.0, + 'current_age': 40 + } + +def calculate_portfolio_value(portfolio_data: Dict[str, Any]) -> float: + """Calculate current portfolio value.""" + total_value = 0.0 + + for account in portfolio_data.get("accounts", []): + cash = float(account.get("cash_balance", 0)) + total_value += cash + + for position in account.get("positions", []): + quantity = float(position.get("quantity", 0)) + instrument = position.get("instrument", {}) + price = float(instrument.get("current_price", 100)) + total_value += quantity * price + + return total_value + +def calculate_asset_allocation(portfolio_data: Dict[str, Any]) -> Dict[str, float]: + """Calculate asset allocation percentages.""" + total_equity = 0.0 + total_bonds = 0.0 + total_real_estate = 0.0 + total_commodities = 0.0 + total_cash = 0.0 + total_value = 0.0 + + for account in portfolio_data.get("accounts", []): + cash = float(account.get("cash_balance", 0)) + total_cash += cash + total_value += cash + + for position in account.get("positions", []): + quantity = float(position.get("quantity", 0)) + instrument = position.get("instrument", {}) + price = float(instrument.get("current_price", 100)) + value = quantity * price + total_value += value + + # Get asset class allocation + asset_allocation = instrument.get("allocation_asset_class", {}) + if asset_allocation: + total_equity += value * asset_allocation.get("equity", 0) / 100 + total_bonds += value * asset_allocation.get("fixed_income", 0) / 100 + total_real_estate += value * asset_allocation.get("real_estate", 0) / 100 + total_commodities += value * asset_allocation.get("commodities", 0) / 100 + + if total_value == 0: + return {"equity": 0, "bonds": 0, "real_estate": 0, "commodities": 0, "cash": 0} + + return { + "equity": total_equity / total_value, + "bonds": total_bonds / total_value, + "real_estate": total_real_estate / total_value, + "commodities": total_commodities / total_value, + "cash": total_cash / total_value, + } + +def run_monte_carlo_simulation( + current_value: float, + years_until_retirement: int, + target_annual_income: float, + asset_allocation: Dict[str, float], + num_simulations: int = 500, +) -> Dict[str, Any]: + """Run Monte Carlo simulation for retirement planning.""" + + # Historical return parameters (annualized) + equity_return_mean = 0.07 + equity_return_std = 0.18 + bond_return_mean = 0.04 + bond_return_std = 0.05 + real_estate_return_mean = 0.06 + real_estate_return_std = 0.12 + + successful_scenarios = 0 + final_values = [] + years_lasted = [] + + for _ in range(num_simulations): + portfolio_value = current_value + + # Accumulation phase + for _ in range(years_until_retirement): + equity_return = random.gauss(equity_return_mean, equity_return_std) + bond_return = random.gauss(bond_return_mean, bond_return_std) + real_estate_return = random.gauss(real_estate_return_mean, real_estate_return_std) + + portfolio_return = ( + asset_allocation["equity"] * equity_return + + asset_allocation["bonds"] * bond_return + + asset_allocation["real_estate"] * real_estate_return + + asset_allocation["cash"] * 0.02 + ) + + portfolio_value = portfolio_value * (1 + portfolio_return) + portfolio_value += 10000 # Annual contribution + + # Retirement phase + retirement_years = 30 + annual_withdrawal = target_annual_income + years_income_lasted = 0 + + for year in range(retirement_years): + if portfolio_value <= 0: + break + + # Inflation adjustment (3% per year) + annual_withdrawal *= 1.03 + + equity_return = random.gauss(equity_return_mean, equity_return_std) + bond_return = random.gauss(bond_return_mean, bond_return_std) + real_estate_return = random.gauss(real_estate_return_mean, real_estate_return_std) + + portfolio_return = ( + asset_allocation["equity"] * equity_return + + asset_allocation["bonds"] * bond_return + + asset_allocation["real_estate"] * real_estate_return + + asset_allocation["cash"] * 0.02 + ) + + portfolio_value = portfolio_value * (1 + portfolio_return) - annual_withdrawal + + if portfolio_value > 0: + years_income_lasted += 1 + + final_values.append(max(0, portfolio_value)) + years_lasted.append(years_income_lasted) + + if years_income_lasted >= retirement_years: + successful_scenarios += 1 + + # Calculate statistics + final_values.sort() + success_rate = (successful_scenarios / num_simulations) * 100 + + # Calculate expected value at retirement + expected_return = ( + asset_allocation["equity"] * equity_return_mean + + asset_allocation["bonds"] * bond_return_mean + + asset_allocation["real_estate"] * real_estate_return_mean + + asset_allocation["cash"] * 0.02 + ) + expected_value_at_retirement = current_value + for _ in range(years_until_retirement): + expected_value_at_retirement *= 1 + expected_return + expected_value_at_retirement += 10000 + + return { + "success_rate": round(success_rate, 1), + "median_final_value": round(final_values[num_simulations // 2], 2), + "percentile_10": round(final_values[num_simulations // 10], 2), + "percentile_90": round(final_values[9 * num_simulations // 10], 2), + "average_years_lasted": round(sum(years_lasted) / len(years_lasted), 1), + "expected_value_at_retirement": round(expected_value_at_retirement, 2), + } + +def generate_projections( + current_value: float, + years_until_retirement: int, + asset_allocation: Dict[str, float], + current_age: int, +) -> list: + """Generate simplified retirement projections.""" + + # Expected returns + expected_return = ( + asset_allocation["equity"] * 0.07 + + asset_allocation["bonds"] * 0.04 + + asset_allocation["real_estate"] * 0.06 + + asset_allocation["cash"] * 0.02 + ) + + projections = [] + portfolio_value = current_value + + # Only show key milestones (every 5 years) + milestone_years = list(range(0, years_until_retirement + 31, 5)) + + for year in milestone_years: + age = current_age + year + + if year <= years_until_retirement: + # Calculate accumulation + for _ in range(min(5, year)): + portfolio_value *= 1 + expected_return + portfolio_value += 10000 + phase = "accumulation" + annual_income = 0 + else: + # Calculate retirement withdrawals + withdrawal_rate = 0.04 + annual_income = portfolio_value * withdrawal_rate + years_in_retirement = min(5, year - years_until_retirement) + for _ in range(years_in_retirement): + portfolio_value = portfolio_value * (1 + expected_return) - annual_income + phase = "retirement" + + if portfolio_value > 0: + projections.append( + { + "year": year, + "age": age, + "portfolio_value": round(portfolio_value, 2), + "annual_income": round(annual_income, 2), + "phase": phase, + } + ) + + return projections + +async def create_agent_and_run(job_id: str, portfolio_data: Dict[str, Any]) -> str: + """Create and run the retirement agent.""" + + # Get user preferences + user_preferences = get_user_preferences(job_id) + + # Create model + model = BedrockModel( + model_id=model_id, + ) + + # Create agent (no tools needed) + agent = Agent( + model=model, + system_prompt=RETIREMENT_INSTRUCTIONS + ) + + # Extract user preferences + years_until_retirement = user_preferences.get("years_until_retirement", 30) + target_income = user_preferences.get("target_retirement_income", 80000) + current_age = user_preferences.get("current_age", 40) + + # Calculate portfolio metrics + portfolio_value = calculate_portfolio_value(portfolio_data) + allocation = calculate_asset_allocation(portfolio_data) + + # Run Monte Carlo simulation + monte_carlo = run_monte_carlo_simulation( + portfolio_value, years_until_retirement, target_income, allocation, num_simulations=500 + ) + + # Generate projections + projections = generate_projections( + portfolio_value, years_until_retirement, allocation, current_age + ) + + # Format comprehensive context for the agent + task = f""" +# Portfolio Analysis Context + +## Current Situation +- Portfolio Value: ${portfolio_value:,.0f} +- Asset Allocation: {", ".join([f"{k.title()}: {v:.0%}" for k, v in allocation.items() if v > 0])} +- Years to Retirement: {years_until_retirement} +- Target Annual Income: ${target_income:,.0f} +- Current Age: {current_age} + +## Monte Carlo Simulation Results (500 scenarios) +- Success Rate: {monte_carlo["success_rate"]}% (probability of sustaining retirement income for 30 years) +- Expected Portfolio Value at Retirement: ${monte_carlo["expected_value_at_retirement"]:,.0f} +- 10th Percentile Outcome: ${monte_carlo["percentile_10"]:,.0f} (worst case) +- Median Final Value: ${monte_carlo["median_final_value"]:,.0f} +- 90th Percentile Outcome: ${monte_carlo["percentile_90"]:,.0f} (best case) +- Average Years Portfolio Lasts: {monte_carlo["average_years_lasted"]} years + +## Key Projections (Milestones) +""" + + for proj in projections[:6]: + if proj["phase"] == "accumulation": + task += f"- Age {proj['age']}: ${proj['portfolio_value']:,.0f} (building wealth)\n" + else: + task += f"- Age {proj['age']}: ${proj['portfolio_value']:,.0f} (annual income: ${proj['annual_income']:,.0f})\n" + + task += f""" + +## Risk Factors to Consider +- Sequence of returns risk (poor returns early in retirement) +- Inflation impact (3% assumed) +- Healthcare costs in retirement +- Longevity risk (living beyond 30 years) +- Market volatility (equity standard deviation: 18%) + +## Safe Withdrawal Rate Analysis +- 4% Rule: ${portfolio_value * 0.04:,.0f} initial annual income +- Target Income: ${target_income:,.0f} +- Gap: ${target_income - (portfolio_value * 0.04):,.0f} + +Your task: Analyze this retirement readiness data and provide a comprehensive retirement analysis including: +1. Clear assessment of retirement readiness +2. Specific recommendations to improve success rate +3. Risk mitigation strategies +4. Action items with timeline + +Provide your analysis in clear markdown format with specific numbers and actionable recommendations. +""" + + # Run the agent + result = agent(task) + + # Extract the text content from the AgentResult + response = result.text if hasattr(result, 'text') else str(result) + + return response + +async def process_retirement_analysis(job_id: str, portfolio_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Process and generate retirement analysis. + + Args: + job_id: Unique job identifier + portfolio_data: Portfolio data to analyze + + Returns: + Processing results + """ + try: + # Run the agent + logger.info(f"Generating retirement analysis for job {job_id}") + response = await create_agent_and_run(job_id, portfolio_data) + + # Save the analysis to database + retirement_payload = { + 'analysis': response, + 'generated_at': datetime.utcnow().isoformat(), + 'agent': 'retirement' + } + + success = db.jobs.update_retirement(job_id, retirement_payload) + + if not success: + logger.error(f"Failed to save retirement analysis for job {job_id}") + # Add debugging - check if job exists + job = db.jobs.find_by_id(job_id) + if job: + logger.error(f"Job exists but update failed. Job status: {job.get('status')}") + else: + logger.error(f"Job {job_id} does not exist in database") + + return { + 'success': success, + 'message': 'Retirement analysis completed' if success else 'Analysis completed but failed to save', + 'final_output': response + } + + except Exception as e: + logger.error(f"Error processing retirement analysis for {job_id}: {e}") + return { + 'success': False, + 'error': str(e), + 'message': f"Failed to generate retirement analysis: {str(e)}" + } + +app = BedrockAgentCoreApp() + +@app.entrypoint +def retirement_agent(payload): + """Main entry point for the retirement agent.""" + try: + logger.info(f"Retirement Agent invoked with payload: {json.dumps(payload)[:500]}") + + # Parse the payload + job_id = payload.get("job_id") + if not job_id: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'job_id is required'}) + } + + portfolio_data = payload.get("portfolio_data") + + # If no portfolio data provided, try to load from database + if not portfolio_data: + try: + job = db.jobs.find_by_id(job_id) + if job: + portfolio_data = job.get('request_payload', {}).get('portfolio_data', {}) + else: + return { + 'statusCode': 404, + 'body': json.dumps({'error': f'Job {job_id} not found'}) + } + except Exception as e: + logger.error(f"Could not load portfolio from database: {e}") + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'No portfolio data provided'}) + } + + # Process the retirement analysis in a single async context + result = asyncio.run(process_retirement_analysis(job_id, portfolio_data)) + + return { + 'statusCode': 200, + 'body': json.dumps(result) + } + + except Exception as e: + logger.error(f"Retirement agent error: {e}", exc_info=True) + return { + 'statusCode': 500, + 'body': json.dumps({'error': str(e)}) + } + +if __name__ == "__main__": + app.run() + # Simple test when run directly + # async def test(): + # payload = { + # "job_id": "test-retirement-123", + # "portfolio_data": { + # "accounts": [ + # { + # "name": "401(k)", + # "type": "retirement", + # "cash_balance": 10000, + # "positions": [ + # { + # "symbol": "SPY", + # "quantity": 100, + # "instrument": { + # "name": "SPDR S&P 500 ETF", + # "current_price": 450, + # "allocation_asset_class": {"equity": 100} + # } + # } + # ] + # } + # ] + # } + # } + # result = await process_retirement_analysis(payload["job_id"], payload["portfolio_data"]) + # print(json.dumps(result, indent=2)) + + # asyncio.run(test()) diff --git a/backend/agent_retirement/requirements.txt b/backend/agent_retirement/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_retirement/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_retirement/src/__init__.py b/backend/agent_retirement/src/__init__.py new file mode 100644 index 00000000..5bc75e95 --- /dev/null +++ b/backend/agent_retirement/src/__init__.py @@ -0,0 +1,51 @@ +""" +Database package for Alex Financial Planner +Provides database models, schemas, and Data API client +""" + +from .client import DataAPIClient +from .models import Database +from .schemas import ( + # Types + RegionType, + AssetClassType, + SectorType, + InstrumentType, + JobType, + JobStatus, + AccountType, + + # Create schemas (for inputs) + InstrumentCreate, + UserCreate, + AccountCreate, + PositionCreate, + JobCreate, + JobUpdate, + + # Response schemas (for outputs) + InstrumentResponse, + PortfolioAnalysis, + RebalanceRecommendation, +) + +__all__ = [ + 'Database', + 'DataAPIClient', + 'InstrumentCreate', + 'UserCreate', + 'AccountCreate', + 'PositionCreate', + 'JobCreate', + 'JobUpdate', + 'InstrumentResponse', + 'PortfolioAnalysis', + 'RebalanceRecommendation', + 'RegionType', + 'AssetClassType', + 'SectorType', + 'InstrumentType', + 'JobType', + 'JobStatus', + 'AccountType', +] \ No newline at end of file diff --git a/backend/agent_retirement/src/client.py b/backend/agent_retirement/src/client.py new file mode 100644 index 00000000..f91994e9 --- /dev/null +++ b/backend/agent_retirement/src/client.py @@ -0,0 +1,310 @@ +""" +Aurora Data API Client Wrapper +Provides a simple interface for database operations +""" + +import boto3 +import json +import os +from typing import List, Dict, Any, Optional, Tuple +from datetime import date, datetime +from decimal import Decimal +from botocore.exceptions import ClientError +import logging + +# Try to load .env file if it exists +try: + from dotenv import load_dotenv + + load_dotenv(override=True) +except ImportError: + pass # dotenv not installed, continue without it + +logger = logging.getLogger(__name__) + + +class DataAPIClient: + """Wrapper for AWS RDS Data API to simplify database operations""" + + def __init__( + self, + cluster_arn: str = None, + secret_arn: str = None, + database: str = None, + region: str = None, + ): + """ + Initialize Data API client + + Args: + cluster_arn: Aurora cluster ARN (or from env AURORA_CLUSTER_ARN) + secret_arn: Secrets Manager ARN (or from env AURORA_SECRET_ARN) + database: Database name (or from env AURORA_DATABASE) + region: AWS region (or from env AWS_REGION) + """ + self.cluster_arn = cluster_arn or os.environ.get("AURORA_CLUSTER_ARN") + self.secret_arn = secret_arn or os.environ.get("AURORA_SECRET_ARN") + self.database = database or os.environ.get("AURORA_DATABASE", "alex") + + if not self.cluster_arn or not self.secret_arn: + raise ValueError( + "Missing required Aurora configuration. " + "Set AURORA_CLUSTER_ARN and AURORA_SECRET_ARN environment variables." + ) + + self.region = os.environ.get("DEFAULT_AWS_REGION", "us-east-1") + self.client = boto3.client("rds-data", region_name=self.region) + + def execute(self, sql: str, parameters: List[Dict] = None) -> Dict: + """ + Execute a SQL statement + + Args: + sql: SQL statement to execute + parameters: Optional list of parameters for prepared statement + + Returns: + Response from Data API + """ + try: + kwargs = { + "resourceArn": self.cluster_arn, + "secretArn": self.secret_arn, + "database": self.database, + "sql": sql, + "includeResultMetadata": True, # Include column names + } + + if parameters: + kwargs["parameters"] = parameters + + response = self.client.execute_statement(**kwargs) + return response + + except ClientError as e: + logger.error(f"Database error: {e}") + raise + + def query(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """ + Execute a SELECT query and return results as list of dicts + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + List of dictionaries with column names as keys + """ + response = self.execute(sql, parameters) + + if "records" not in response: + return [] + + # Extract column names + columns = [col["name"] for col in response.get("columnMetadata", [])] + + # Convert records to dictionaries + results = [] + for record in response["records"]: + row = {} + for i, col in enumerate(columns): + value = self._extract_value(record[i]) + row[col] = value + results.append(row) + + return results + + def query_one(self, sql: str, parameters: List[Dict] = None) -> Optional[Dict]: + """ + Execute a SELECT query and return first result + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + Dictionary with column names as keys, or None if no results + """ + results = self.query(sql, parameters) + return results[0] if results else None + + def insert(self, table: str, data: Dict, returning: str = None) -> str: + """ + Insert a record into a table + + Args: + table: Table name + data: Dictionary of column names and values + returning: Column to return (e.g., 'id', 'clerk_user_id') + + Returns: + Value of returning column if specified + """ + columns = list(data.keys()) + placeholders = [] + + # Check if columns need type casting + for col in columns: + if isinstance(data[col], (dict, list)): + placeholders.append(f":{col}::jsonb") + elif isinstance(data[col], Decimal): + placeholders.append(f":{col}::numeric") + elif isinstance(data[col], date) and not isinstance(data[col], datetime): + placeholders.append(f":{col}::date") + elif isinstance(data[col], datetime): + placeholders.append(f":{col}::timestamp") + else: + placeholders.append(f":{col}") + + sql = f""" + INSERT INTO {table} ({", ".join(columns)}) + VALUES ({", ".join(placeholders)}) + """ + + # Add RETURNING clause if specified + if returning: + sql += f" RETURNING {returning}" + + parameters = self._build_parameters(data) + response = self.execute(sql, parameters) + + # Return value if RETURNING was used + if returning and response.get("records"): + return self._extract_value(response["records"][0][0]) + return None + + def update(self, table: str, data: Dict, where: str, where_params: Dict = None) -> int: + """ + Update records in a table + + Args: + table: Table name + data: Dictionary of columns to update + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of affected rows + """ + # Build SET clause with type casting where needed + set_parts = [] + for col, val in data.items(): + if isinstance(val, (dict, list)): + set_parts.append(f"{col} = :{col}::jsonb") + elif isinstance(val, Decimal): + set_parts.append(f"{col} = :{col}::numeric") + elif isinstance(val, date) and not isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::date") + elif isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::timestamp") + else: + set_parts.append(f"{col} = :{col}") + + set_clause = ", ".join(set_parts) + + sql = f""" + UPDATE {table} + SET {set_clause} + WHERE {where} + """ + + # Combine data and where parameters + all_params = {**data, **(where_params or {})} + parameters = self._build_parameters(all_params) + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def delete(self, table: str, where: str, where_params: Dict = None) -> int: + """ + Delete records from a table + + Args: + table: Table name + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of deleted rows + """ + sql = f"DELETE FROM {table} WHERE {where}" + parameters = self._build_parameters(where_params) if where_params else None + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def begin_transaction(self) -> str: + """Begin a database transaction""" + response = self.client.begin_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, database=self.database + ) + return response["transactionId"] + + def commit_transaction(self, transaction_id: str): + """Commit a database transaction""" + self.client.commit_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def rollback_transaction(self, transaction_id: str): + """Rollback a database transaction""" + self.client.rollback_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def _build_parameters(self, data: Dict) -> List[Dict]: + """Convert dictionary to Data API parameter format""" + if not data: + return [] + + parameters = [] + for key, value in data.items(): + param = {"name": key} + + if value is None: + param["value"] = {"isNull": True} + elif isinstance(value, bool): + param["value"] = {"booleanValue": value} + elif isinstance(value, int): + param["value"] = {"longValue": value} + elif isinstance(value, float): + param["value"] = {"doubleValue": value} + elif isinstance(value, Decimal): + param["value"] = {"stringValue": str(value)} + elif isinstance(value, (date, datetime)): + param["value"] = {"stringValue": value.isoformat()} + elif isinstance(value, dict): + param["value"] = {"stringValue": json.dumps(value)} + elif isinstance(value, list): + param["value"] = {"stringValue": json.dumps(value)} + else: + param["value"] = {"stringValue": str(value)} + + parameters.append(param) + + return parameters + + def _extract_value(self, field: Dict) -> Any: + """Extract value from Data API field response""" + if field.get("isNull"): + return None + elif "booleanValue" in field: + return field["booleanValue"] + elif "longValue" in field: + return field["longValue"] + elif "doubleValue" in field: + return field["doubleValue"] + elif "stringValue" in field: + value = field["stringValue"] + # Try to parse JSON if it looks like JSON + if value and value[0] in ["{", "["]: + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + elif "blobValue" in field: + return field["blobValue"] + else: + return None diff --git a/backend/agent_retirement/src/models.py b/backend/agent_retirement/src/models.py new file mode 100644 index 00000000..903e3594 --- /dev/null +++ b/backend/agent_retirement/src/models.py @@ -0,0 +1,320 @@ +""" +Database models and query builders +""" + +from typing import Dict, List, Optional, Any +from datetime import datetime, date +from decimal import Decimal +from .client import DataAPIClient +from .schemas import ( + InstrumentCreate, UserCreate, AccountCreate, + PositionCreate, JobCreate, JobUpdate +) + + +class BaseModel: + """Base class for database models""" + + table_name = None + + def __init__(self, db: DataAPIClient): + self.db = db + if not self.table_name: + raise ValueError("table_name must be defined") + + def find_by_id(self, id: Any) -> Optional[Dict]: + """Find a record by ID""" + sql = f"SELECT * FROM {self.table_name} WHERE id = :id::uuid" + return self.db.query_one(sql, [{'name': 'id', 'value': {'stringValue': str(id)}}]) + + def find_all(self, limit: int = 100, offset: int = 0) -> List[Dict]: + """Find all records with pagination""" + sql = f"SELECT * FROM {self.table_name} LIMIT :limit OFFSET :offset" + params = [ + {'name': 'limit', 'value': {'longValue': limit}}, + {'name': 'offset', 'value': {'longValue': offset}} + ] + return self.db.query(sql, params) + + def create(self, data: Dict, returning: str = 'id') -> str: + """Create a new record""" + return self.db.insert(self.table_name, data, returning=returning) + + def update(self, id: Any, data: Dict) -> int: + """Update a record by ID""" + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': str(id)}) + + def delete(self, id: Any) -> int: + """Delete a record by ID""" + return self.db.delete(self.table_name, "id = :id::uuid", {'id': str(id)}) + + +class Users(BaseModel): + """Users table operations""" + table_name = 'users' + + def find_by_clerk_id(self, clerk_user_id: str) -> Optional[Dict]: + """Find user by Clerk ID""" + sql = f"SELECT * FROM {self.table_name} WHERE clerk_user_id = :clerk_id" + params = [{'name': 'clerk_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query_one(sql, params) + + def create_user(self, clerk_user_id: str, display_name: str = None, + years_until_retirement: int = None, + target_retirement_income: Decimal = None) -> str: + """Create a new user""" + data = { + 'clerk_user_id': clerk_user_id, + 'display_name': display_name, + 'years_until_retirement': years_until_retirement, + 'target_retirement_income': target_retirement_income + } + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + return self.db.insert(self.table_name, data, returning='clerk_user_id') + + +class Instruments(BaseModel): + """Instruments table operations""" + table_name = 'instruments' + + def find_all(self, limit: int = None, offset: int = 0) -> List[Dict]: + """Find all instruments - no limit by default for autocomplete""" + sql = f"SELECT * FROM {self.table_name} ORDER BY symbol" + return self.db.query(sql, []) + + def find_by_symbol(self, symbol: str) -> Optional[Dict]: + """Find instrument by symbol""" + sql = f"SELECT * FROM {self.table_name} WHERE symbol = :symbol" + params = [{'name': 'symbol', 'value': {'stringValue': symbol}}] + return self.db.query_one(sql, params) + + def create_instrument(self, instrument: InstrumentCreate) -> str: + """Create a new instrument with validation""" + # Validate using Pydantic + validated = instrument.model_dump() + + # Convert allocations to JSON strings for storage + data = { + 'symbol': validated['symbol'], + 'name': validated['name'], + 'instrument_type': validated['instrument_type'], + 'allocation_regions': validated['allocation_regions'], + 'allocation_sectors': validated['allocation_sectors'], + 'allocation_asset_class': validated['allocation_asset_class'] + } + + return self.db.insert(self.table_name, data, returning='symbol') + + def find_by_type(self, instrument_type: str) -> List[Dict]: + """Find all instruments of a specific type""" + sql = f"SELECT * FROM {self.table_name} WHERE instrument_type = :type ORDER BY symbol" + params = [{'name': 'type', 'value': {'stringValue': instrument_type}}] + return self.db.query(sql, params) + + def search(self, query: str) -> List[Dict]: + """Search instruments by symbol or name""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE LOWER(symbol) LIKE LOWER(:query) + OR LOWER(name) LIKE LOWER(:query) + ORDER BY symbol + LIMIT 20 + """ + params = [{'name': 'query', 'value': {'stringValue': f'%{query}%'}}] + return self.db.query(sql, params) + + +class Accounts(BaseModel): + """Accounts table operations""" + table_name = 'accounts' + + def find_by_user(self, clerk_user_id: str) -> List[Dict]: + """Find all accounts for a user""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + """ + params = [{'name': 'user_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query(sql, params) + + def create_account(self, clerk_user_id: str, account_name: str, + account_purpose: str = None, cash_balance: Decimal = Decimal('0'), + cash_interest: Decimal = Decimal('0')) -> str: + """Create a new account""" + data = { + 'clerk_user_id': clerk_user_id, + 'account_name': account_name, + 'account_purpose': account_purpose, + 'cash_balance': cash_balance, + 'cash_interest': cash_interest + } + return self.db.insert(self.table_name, data, returning='id') + + +class Positions(BaseModel): + """Positions table operations""" + table_name = 'positions' + + def find_by_account(self, account_id: str) -> List[Dict]: + """Find all positions in an account""" + sql = f""" + SELECT p.*, i.name as instrument_name, i.instrument_type, i.current_price + FROM {self.table_name} p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + ORDER BY p.symbol + """ + params = [{'name': 'account_id', 'value': {'stringValue': account_id}}] + return self.db.query(sql, params) + + def get_portfolio_value(self, account_id: str) -> Dict: + """Calculate total portfolio value using current prices from instruments table""" + sql = """ + SELECT + COUNT(DISTINCT p.symbol) as num_positions, + SUM(p.quantity * i.current_price) as total_value, + SUM(p.quantity) as total_shares + FROM positions p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}} + ] + result = self.db.query_one(sql, params) + if result: + return { + 'num_positions': result.get('num_positions', 0), + 'total_value': float(result.get('total_value', 0)) if result.get('total_value') else 0, + 'total_shares': float(result.get('total_shares', 0)) if result.get('total_shares') else 0 + } + return {'num_positions': 0, 'total_value': 0, 'total_shares': 0} + + def add_position(self, account_id: str, symbol: str, quantity: Decimal) -> str: + """Add or update a position""" + # Use UPSERT to handle existing positions + sql = """ + INSERT INTO positions (account_id, symbol, quantity, as_of_date) + VALUES (:account_id::uuid, :symbol, :quantity::numeric, :as_of_date::date) + ON CONFLICT (account_id, symbol) + DO UPDATE SET + quantity = EXCLUDED.quantity, + as_of_date = EXCLUDED.as_of_date, + updated_at = NOW() + RETURNING id + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}}, + {'name': 'symbol', 'value': {'stringValue': symbol}}, + {'name': 'quantity', 'value': {'stringValue': str(quantity)}}, + {'name': 'as_of_date', 'value': {'stringValue': date.today().isoformat()}} + ] + response = self.db.execute(sql, params) + if response.get('records'): + return response['records'][0][0].get('stringValue') + return None + + +class Jobs(BaseModel): + """Jobs table operations""" + table_name = 'jobs' + + def create_job(self, clerk_user_id: str, job_type: str, + request_payload: Dict = None) -> str: + """Create a new job""" + data = { + 'clerk_user_id': clerk_user_id, + 'job_type': job_type, + 'status': 'pending', + 'request_payload': request_payload + } + return self.db.insert(self.table_name, data, returning='id') + + def update_status(self, job_id: str, status: str, error_message: str = None) -> int: + """Update job status""" + data = {'status': status} + + if status == 'running': + data['started_at'] = datetime.utcnow() + elif status in ['completed', 'failed', 'max_tokens_exceeded']: + data['completed_at'] = datetime.utcnow() + + if error_message: + data['error_message'] = error_message + + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_report(self, job_id: str, report_payload: Dict) -> int: + """Update job with Reporter agent's analysis""" + data = {'report_payload': report_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_charts(self, job_id: str, charts_payload: Dict) -> int: + """Update job with Charter agent's visualization data""" + data = {'charts_payload': charts_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_retirement(self, job_id: str, retirement_payload: Dict) -> int: + """Update job with Retirement agent's projections""" + data = {'retirement_payload': retirement_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_summary(self, job_id: str, summary_payload: Dict) -> int: + """Update job with Planner's final summary""" + data = {'summary_payload': summary_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def find_by_user(self, clerk_user_id: str, status: str = None, + limit: int = 20) -> List[Dict]: + """Find jobs for a user""" + if status: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id AND status = :status + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'status', 'value': {'stringValue': status}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + else: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + + return self.db.query(sql, params) + + +class Database: + """Main database interface providing access to all models""" + + def __init__(self, cluster_arn: str = None, secret_arn: str = None, + database: str = None, region: str = None): + """Initialize database with all model classes""" + self.client = DataAPIClient(cluster_arn, secret_arn, database, region) + + # Initialize all models + self.users = Users(self.client) + self.instruments = Instruments(self.client) + self.accounts = Accounts(self.client) + self.positions = Positions(self.client) + self.jobs = Jobs(self.client) + + def execute_raw(self, sql: str, parameters: List[Dict] = None) -> Dict: + """Execute raw SQL for complex queries""" + return self.client.execute(sql, parameters) + + def query_raw(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """Execute raw SELECT query""" + return self.client.query(sql, parameters) \ No newline at end of file diff --git a/backend/agent_retirement/src/schemas.py b/backend/agent_retirement/src/schemas.py new file mode 100644 index 00000000..44f16514 --- /dev/null +++ b/backend/agent_retirement/src/schemas.py @@ -0,0 +1,284 @@ +""" +Pydantic schemas for data validation and LLM tool interfaces +These models serve as both database validation and LLM structured output schemas +""" + +from typing import Dict, Literal, Optional, List +from pydantic import BaseModel, Field, field_validator +from decimal import Decimal +from datetime import date, datetime + + +# Define allowed values as Literals for LLM compatibility +RegionType = Literal[ + "north_america", + "europe", + "asia", + "latin_america", + "africa", + "middle_east", + "oceania", + "global", + "international", # For mixed non-US +] + +AssetClassType = Literal[ + "equity", "fixed_income", "real_estate", "commodities", "cash", "alternatives" +] + +SectorType = Literal[ + "technology", + "healthcare", + "financials", + "consumer_discretionary", + "consumer_staples", + "industrials", + "energy", + "materials", + "utilities", + "real_estate", + "communication", + "treasury", + "corporate", + "mortgage", + "government_related", + "commodities", + "diversified", + "other", +] + +InstrumentType = Literal["etf", "mutual_fund", "stock", "bond", "bond_fund", "commodity", "reit"] + +JobType = Literal[ + "portfolio_analysis", + "rebalance_recommendation", + "retirement_projection", + "risk_assessment", + "tax_optimization", + "instrument_research", +] + +JobStatus = Literal["pending", "running", "completed", "failed", "max_tokens_exceeded"] + +AccountType = Literal[ + "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" +] + + +class AllocationDict(BaseModel): + """Base class for allocation dictionaries ensuring they sum to 100""" + + @field_validator("*", mode="after") + def validate_sum(cls, v, info): + """Ensure allocation percentages sum to 100""" + if isinstance(v, dict): + total = sum(v.values()) + if abs(total - 100) > 3: # Allow small floating point errors + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class RegionAllocation(BaseModel): + """Geographic allocation of an instrument""" + + allocations: Dict[RegionType, float] = Field( + description="Percentage allocation by geographic region. Must sum to 100.", + example={"north_america": 60, "europe": 25, "asia": 15}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Region allocations must sum to 100, got {total}") + return v + + +class AssetClassAllocation(BaseModel): + """Asset class allocation of an instrument""" + + allocations: Dict[AssetClassType, float] = Field( + description="Percentage allocation by asset class. Must sum to 100.", + example={"equity": 80, "fixed_income": 20}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Asset class allocations must sum to 100, got {total}") + return v + + +class SectorAllocation(BaseModel): + """Sector allocation of an instrument""" + + allocations: Dict[SectorType, float] = Field( + description="Percentage allocation by market sector. Must sum to 100.", + example={"technology": 30, "healthcare": 25, "financials": 20, "other": 25}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Sector allocations must sum to 100, got {total}") + return v + + +class InstrumentCreate(BaseModel): + """Schema for creating a new instrument - suitable for LLM tool input""" + + symbol: str = Field( + description="The ticker symbol of the instrument (e.g., 'SPY', 'BND')", + min_length=1, + max_length=20, + ) + name: str = Field(description="Full name of the instrument", min_length=1, max_length=255) + instrument_type: InstrumentType = Field(description="The type of financial instrument") + current_price: Optional[Decimal] = Field( + None, + description="Current price of the instrument for portfolio calculations", + ge=0, + le=999999, + ) + allocation_regions: Dict[RegionType, float] = Field( + description="Geographic allocation percentages. Must sum to 100.", + example={"north_america": 100}, + ) + allocation_sectors: Dict[SectorType, float] = Field( + description="Sector allocation percentages. Must sum to 100.", + example={"technology": 40, "healthcare": 30, "financials": 30}, + ) + allocation_asset_class: Dict[AssetClassType, float] = Field( + description="Asset class allocation percentages. Must sum to 100.", example={"equity": 100} + ) + + @field_validator("allocation_regions", "allocation_sectors", "allocation_asset_class") + def validate_allocations(cls, v): + """Ensure all allocations sum to 100""" + if not v: + raise ValueError("Allocation cannot be empty") + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class InstrumentResponse(InstrumentCreate): + """Schema for instrument responses from database""" + + created_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + """Schema for creating a user - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="Unique identifier from Clerk authentication system") + display_name: Optional[str] = Field(None, description="User's display name", max_length=255) + years_until_retirement: Optional[int] = Field( + None, description="Number of years until the user plans to retire", ge=0, le=100 + ) + target_retirement_income: Optional[Decimal] = Field( + None, description="Annual income goal in retirement (in dollars)", ge=0, decimal_places=2 + ) + asset_class_targets: Optional[Dict[AssetClassType, float]] = Field( + default={"equity": 70, "fixed_income": 30}, + description="Target allocation percentages for rebalancing. Must sum to 100.", + ) + region_targets: Optional[Dict[RegionType, float]] = Field( + default={"north_america": 50, "international": 50}, + description="Target geographic allocation for rebalancing. Must sum to 100.", + ) + + +class AccountCreate(BaseModel): + """Schema for creating an account - suitable for LLM tool input""" + + account_name: str = Field( + description="Name of the account (e.g., '401k', 'Roth IRA')", min_length=1, max_length=255 + ) + account_purpose: Optional[str] = Field(None, description="Purpose or goal of this account") + cash_balance: Decimal = Field( + default=Decimal("0"), + description="Uninvested cash balance in the account", + ge=0, + decimal_places=2, + ) + cash_interest: Decimal = Field( + default=Decimal("0"), + description="Annual interest rate on cash (e.g., 0.045 for 4.5%)", + ge=0, + le=1, + decimal_places=4, + ) + + +class PositionCreate(BaseModel): + """Schema for creating a position - suitable for LLM tool input""" + + account_id: str = Field(description="UUID of the account holding this position") + symbol: str = Field(description="Ticker symbol of the instrument", min_length=1, max_length=20) + quantity: Decimal = Field( + description="Number of shares (supports fractional shares)", gt=0, decimal_places=8 + ) + as_of_date: Optional[date] = Field( + default_factory=date.today, description="Date of this position snapshot" + ) + + +class JobCreate(BaseModel): + """Schema for creating a job - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="User requesting this job") + job_type: JobType = Field(description="Type of analysis or operation to perform") + request_payload: Optional[Dict] = Field(None, description="Input parameters for the job") + + +class JobUpdate(BaseModel): + """Schema for updating job status - suitable for LLM tool output""" + + status: JobStatus = Field(description="Current status of the job") + result_payload: Optional[Dict] = Field(None, description="Results of the completed job") + error_message: Optional[str] = Field(None, description="Error details if job failed") + + +class PortfolioAnalysis(BaseModel): + """Schema for portfolio analysis results - LLM structured output""" + + total_value: Decimal = Field(description="Total portfolio value in dollars", decimal_places=2) + asset_allocation: Dict[AssetClassType, float] = Field( + description="Current asset class allocation percentages" + ) + region_allocation: Dict[RegionType, float] = Field( + description="Current geographic allocation percentages" + ) + sector_allocation: Dict[SectorType, float] = Field( + description="Current sector allocation percentages" + ) + risk_score: int = Field( + description="Risk score from 1 (conservative) to 10 (aggressive)", ge=1, le=10 + ) + recommendations: List[str] = Field( + description="List of actionable recommendations for the portfolio" + ) + + +class RebalanceRecommendation(BaseModel): + """Schema for rebalancing recommendations - LLM structured output""" + + current_allocation: Dict[str, float] = Field( + description="Current allocation by instrument symbol" + ) + target_allocation: Dict[str, float] = Field( + description="Recommended target allocation by symbol" + ) + trades: List[Dict] = Field( + description="List of trades needed to rebalance", + example=[ + {"symbol": "SPY", "action": "sell", "quantity": 10}, + {"symbol": "BND", "action": "buy", "quantity": 50}, + ], + ) + rationale: str = Field(description="Explanation of why these changes are recommended") diff --git a/backend/agent_retirement/test_full.py b/backend/agent_retirement/test_full.py new file mode 100644 index 00000000..38a65b76 --- /dev/null +++ b/backend/agent_retirement/test_full.py @@ -0,0 +1,165 @@ +""" +Full test for the agent_retirement with actual Bedrock calls +""" + +import os +import json +import asyncio +import uuid +from dotenv import load_dotenv + +load_dotenv(override=True) + +async def test_full(): + """Test the retirement agent with actual Bedrock calls""" + + # Import database to create a real job + from src import Database + from src.schemas import JobCreate + + # Create a real user and job in the database + db = Database() + + # Create test user first + test_user_id = "test_user_retirement_full_001" + try: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User Retirement Full", + years_until_retirement=25, + target_retirement_income=75000 + ) + print(f"Created test user: {test_user_id}") + except Exception as e: + print(f"User might already exist: {e}") + + # Create test job + job_create = JobCreate( + clerk_user_id=test_user_id, + job_type="portfolio_analysis", + request_payload={"test": True} + ) + test_job_id = db.jobs.create(job_create.model_dump()) + print(f"Created test job in database: {test_job_id}") + + # Test payload with realistic retirement data + payload = { + "job_id": test_job_id, + "portfolio_data": { + "accounts": [ + { + "name": "401(k)", + "type": "retirement", + "cash_balance": 10000, + "positions": [ + { + "symbol": "SPY", + "quantity": 100, + "instrument": { + "name": "SPDR S&P 500 ETF", + "current_price": 450, + "allocation_asset_class": {"equity": 100.0}, + "allocation_regions": {"north_america": 100.0}, + "allocation_sectors": { + "technology": 28.5, + "healthcare": 14.2, + "financials": 13.1, + "consumer_discretionary": 10.8, + "other": 33.4 + } + }, + }, + { + "symbol": "BND", + "quantity": 100, + "instrument": { + "name": "Vanguard Total Bond Market ETF", + "current_price": 75, + "allocation_asset_class": {"fixed_income": 100.0}, + "allocation_regions": {"north_america": 100.0}, + "allocation_sectors": { + "treasury": 40.0, + "corporate": 35.0, + "mortgage": 25.0 + } + }, + } + ], + }, + { + "name": "IRA", + "type": "retirement", + "cash_balance": 5000, + "positions": [ + { + "symbol": "VTI", + "quantity": 50, + "instrument": { + "name": "Vanguard Total Stock Market ETF", + "current_price": 250, + "allocation_asset_class": {"equity": 100.0}, + "allocation_regions": {"north_america": 100.0} + }, + } + ], + } + ] + } + } + + try: + # Import and test + from agent import process_retirement_analysis + + print("🚀 Running full retirement agent test...") + + # Calculate total portfolio value for display + total_value = 0 + for account in payload['portfolio_data']['accounts']: + total_value += account['cash_balance'] + for position in account['positions']: + total_value += position['quantity'] * position['instrument']['current_price'] + + print(f"Total portfolio value: ${total_value:,}") + + result = await process_retirement_analysis( + payload["job_id"], + payload["portfolio_data"] + ) + + print("\n" + "="*50) + print("RESULT:") + print("="*50) + print(json.dumps(result, indent=2)) + + if result.get("success"): + print("\n" + "="*50) + print("GENERATED RETIREMENT ANALYSIS:") + print("="*50) + print(result.get("final_output", "No output")) + print("✅ Full test completed successfully!") + else: + print("❌ Test failed:", result.get("error")) + + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + + finally: + # Clean up - delete the test job and user + try: + db.jobs.delete(test_job_id) + print(f"\n🧹 Deleted test job: {test_job_id}") + except Exception as e: + print(f"⚠️ Failed to delete test job: {e}") + + try: + # Delete user using clerk_user_id + db.client.delete("users", "clerk_user_id = :clerk_id", {"clerk_id": test_user_id}) + print(f"🧹 Deleted test user: {test_user_id}") + except Exception as e: + print(f"⚠️ Failed to delete test user: {e}") + +if __name__ == "__main__": + asyncio.run(test_full()) \ No newline at end of file diff --git a/backend/agent_retirement/test_simple.py b/backend/agent_retirement/test_simple.py new file mode 100644 index 00000000..ce028d3c --- /dev/null +++ b/backend/agent_retirement/test_simple.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +Simple test for Agent Retirement +""" + +import json +from dotenv import load_dotenv + +load_dotenv(override=True) + +from src import Database +from src.schemas import JobCreate +from agent import retirement_agent + +def test_retirement(): + """Test the agent retirement with simple portfolio data""" + + # Create a real user and job in the database + db = Database() + + # Create test user first + test_user_id = "test_user_retirement_001" + try: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User Retirement", + years_until_retirement=25, + target_retirement_income=75000 + ) + print(f"Created test user: {test_user_id}") + except Exception as e: + print(f"User might already exist: {e}") + + # Create test job + job_create = JobCreate( + clerk_user_id=test_user_id, + job_type="portfolio_analysis", + request_payload={"test": True} + ) + job_id = db.jobs.create(job_create.model_dump()) + print(f"Created test job: {job_id}") + + test_payload = { + "job_id": job_id, + "portfolio_data": { + "accounts": [ + { + "name": "401(k)", + "type": "retirement", + "cash_balance": 10000, + "positions": [ + { + "symbol": "SPY", + "quantity": 100, + "instrument": { + "name": "SPDR S&P 500 ETF", + "current_price": 450, + "allocation_asset_class": {"equity": 100.0} + } + }, + { + "symbol": "BND", + "quantity": 100, + "instrument": { + "name": "Vanguard Total Bond Market ETF", + "current_price": 75, + "allocation_asset_class": {"fixed_income": 100.0} + } + } + ] + } + ] + } + } + + print("Testing Agent Retirement...") + print("=" * 60) + + result = retirement_agent(test_payload) + + print(f"Status Code: {result['statusCode']}") + + if result['statusCode'] == 200: + body = json.loads(result['body']) + print(f"Success: {body.get('success', False)}") + print(f"Message: {body.get('message', 'N/A')}") + + # Check what was actually saved in the database + print("\n" + "=" * 60) + print("CHECKING DATABASE CONTENT") + print("=" * 60) + + job = db.jobs.find_by_id(job_id) + if job and job.get('retirement_payload'): + payload = job['retirement_payload'] + print(f"✅ Retirement analysis data found in database") + print(f"Payload keys: {list(payload.keys())}") + + if 'analysis' in payload: + content = payload['analysis'] + print(f"\nContent type: {type(content).__name__}") + + if isinstance(content, str): + print(f"Analysis length: {len(content)} characters") + + # Check if it contains reasoning artifacts + reasoning_indicators = [ + "I need to", + "I will", + "Let me", + "First,", + "I should", + "I'll", + "Now I", + "Next,", + ] + + contains_reasoning = any(indicator.lower() in content.lower() for indicator in reasoning_indicators) + + if contains_reasoning: + print("⚠️ WARNING: Analysis may contain reasoning/thinking text") + else: + print("✅ Analysis appears to be final output only (no reasoning detected)") + + # Show first 500 characters and last 200 characters + print(f"\nFirst 500 characters:") + print("-" * 40) + print(content[:500]) + print("-" * 40) + + if len(content) > 700: + print(f"\nLast 200 characters:") + print("-" * 40) + print(content[-200:]) + print("-" * 40) + else: + print(f"⚠️ Content is not a string: {type(content)}") + print(f"Content: {str(content)[:200]}") + + print(f"\nGenerated at: {payload.get('generated_at', 'N/A')}") + print(f"Agent: {payload.get('agent', 'N/A')}") + else: + print("❌ No retirement analysis data found in database") + else: + print(f"Error: {result['body']}") + + # Clean up - delete the test job and user + try: + db.jobs.delete(job_id) + print(f"\n🧹 Deleted test job: {job_id}") + except Exception as e: + print(f"⚠️ Failed to delete test job: {e}") + + try: + # Delete user using clerk_user_id + db.client.delete("users", "clerk_user_id = :clerk_id", {"clerk_id": test_user_id}) + print(f"🧹 Deleted test user: {test_user_id}") + except Exception as e: + print(f"⚠️ Failed to delete test user: {e}") + + print("=" * 60) + +if __name__ == "__main__": + test_retirement() \ No newline at end of file diff --git a/backend/agent_retirement/utils.py b/backend/agent_retirement/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/agent_retirement/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/agent_tagger/.bedrock_agentcore.yaml b/backend/agent_tagger/.bedrock_agentcore.yaml new file mode 100644 index 00000000..787ee90c --- /dev/null +++ b/backend/agent_tagger/.bedrock_agentcore.yaml @@ -0,0 +1,41 @@ +default_agent: tagger +agents: + tagger: + name: tagger + entrypoint: /Users/fotis/Documents/CV/Learning/AI in production/alex/backend/agent_tagger/agent.py + platform: linux/arm64 + container_runtime: docker + source_path: null + aws: + execution_role: arn:aws:iam::717174128108:role/agentcore-tagger-role + execution_role_auto_create: false + account: '717174128108' + region: us-east-1 + ecr_repository: 717174128108.dkr.ecr.us-east-1.amazonaws.com/bedrock-agentcore-tagger + ecr_auto_create: false + network_configuration: + network_mode: PUBLIC + network_mode_config: null + protocol_configuration: + server_protocol: HTTP + observability: + enabled: true + bedrock_agentcore: + agent_id: tagger-GtQvrf9E4M + agent_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:runtime/tagger-GtQvrf9E4M + agent_session_id: null + codebuild: + project_name: bedrock-agentcore-tagger-builder + execution_role: arn:aws:iam::717174128108:role/AmazonBedrockAgentCoreSDKCodeBuild-us-east-1-eefd273c6e + source_bucket: bedrock-agentcore-codebuild-sources-717174128108-us-east-1 + memory: + mode: STM_ONLY + memory_id: tagger_mem-HYmm4SF6NS + memory_arn: arn:aws:bedrock-agentcore:us-east-1:717174128108:memory/tagger_mem-HYmm4SF6NS + memory_name: tagger_mem + event_expiry_days: 30 + first_invoke_memory_check_done: false + was_created_by_toolkit: false + authorizer_configuration: null + request_header_configuration: null + oauth_configuration: null diff --git a/backend/agent_tagger/.dockerignore b/backend/agent_tagger/.dockerignore new file mode 100644 index 00000000..bf13996c --- /dev/null +++ b/backend/agent_tagger/.dockerignore @@ -0,0 +1,69 @@ +# Build artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Python cache +__pycache__/ +__pycache__* +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +.env +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +.coverage* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.mypy_cache/ +.ruff_cache/ + +# Development +*.log +*.bak +*.swp +*.swo +*~ +.DS_Store + +# IDEs +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Project specific +tests/ + +# Bedrock AgentCore specific - keep config but exclude runtime files +.bedrock_agentcore.yaml +.dockerignore +.bedrock_agentcore/ + +# Keep wheelhouse for offline installations +# wheelhouse/ diff --git a/backend/agent_tagger/.gitignore b/backend/agent_tagger/.gitignore new file mode 100644 index 00000000..8eba6c8d --- /dev/null +++ b/backend/agent_tagger/.gitignore @@ -0,0 +1 @@ +src/ diff --git a/backend/agent_tagger/Dockerfile b/backend/agent_tagger/Dockerfile new file mode 100644 index 00000000..bf5e11c2 --- /dev/null +++ b/backend/agent_tagger/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app + +# All environment variables in one layer +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + AWS_REGION=us-east-1 \ + AWS_DEFAULT_REGION=us-east-1 \ + BEDROCK_AGENTCORE_MEMORY_ID=tagger_mem-HYmm4SF6NS \ + BEDROCK_AGENTCORE_MEMORY_NAME=tagger_mem + + + +COPY requirements.txt requirements.txt +# Install from requirements file +RUN uv pip install -r requirements.txt + + + + +RUN uv pip install aws-opentelemetry-distro>=0.10.1 + + +# Signal that this is running in Docker for host binding logic +ENV DOCKER_CONTAINER=1 + +# Create non-root user +RUN useradd -m -u 1000 bedrock_agentcore +USER bedrock_agentcore + +EXPOSE 9000 +EXPOSE 8000 +EXPOSE 8080 + +# Copy entire project (respecting .dockerignore) +COPY . . + +# Use the full module path + +CMD ["opentelemetry-instrument", "python", "-m", "agent"] diff --git a/backend/agent_tagger/agent.py b/backend/agent_tagger/agent.py new file mode 100644 index 00000000..d4fae5a6 --- /dev/null +++ b/backend/agent_tagger/agent.py @@ -0,0 +1,519 @@ + +""" +InstrumentTagger Agent - Classifies financial instruments using OpenAI Agents SDK. +Simplified version for testing and direct usage. +""" + +import os +import json +import asyncio +import logging +from typing import List, Dict, Any +from decimal import Decimal +from unittest import result + +# Load environment variables from SSM at startup +import sys +sys.path.append('/opt/python') # Add common layer path if available +try: + from utils import load_env_from_ssm + load_env_from_ssm() + print("✅ Loaded environment variables from SSM") +except Exception as e: + print(f"⚠️ Could not load environment from SSM: {e}") + # Fallback to local .env file + try: + from dotenv import load_dotenv + load_dotenv() + print("✅ Loaded environment variables from .env file") + except ImportError: + print("⚠️ python-dotenv not available, skipping .env file loading") + except Exception as e2: + print(f"⚠️ Could not load .env file: {e2}") + +from pydantic import BaseModel, Field, field_validator, ConfigDict +from strands import Agent +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from src.schemas import InstrumentCreate + +from strands.models import BedrockModel + +import sys +import os + +# Add current directory to Python path for src imports +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'database'))) +from src import Database + +db = Database() + + +# from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type + +# Load environment variables + + +# Configure logging +logger = logging.getLogger(__name__) + +model_id = "us.anthropic.claude-3-7-sonnet-20250219-v1:0" +# Get configuration +BEDROCK_MODEL_ID = os.getenv("BEDROCK_MODEL_ID", model_id) +BEDROCK_REGION = os.getenv("BEDROCK_REGION", "us-west-2") + +# Tagger instructions +TAGGER_INSTRUCTIONS = """You are an expert financial instrument classifier responsible for categorizing ETFs, stocks, and other securities. + +Your task is to accurately classify financial instruments by providing: +1. Current market price per share in USD +2. Exact allocation percentages for: + - Asset classes (equity, fixed_income, real_estate, commodities, cash, alternatives) + - Regions (north_america, europe, asia, etc.) + - Sectors (technology, healthcare, financials, etc.) + +Important rules: +- Each allocation category MUST sum to exactly 100.0 +- Use your knowledge of the instrument to provide accurate allocations +- For ETFs, consider the underlying holdings +- For individual stocks, allocate 100% to the appropriate categories +- Be precise with decimal values to ensure totals equal 100.0 + +Examples: +- SPY (S&P 500 ETF): 100% equity, 100% north_america, distributed across sectors based on S&P 500 composition +- BND (Bond ETF): 100% fixed_income, 100% north_america, split between treasury and corporate +- AAPL (Apple stock): 100% equity, 100% north_america, 100% technology +- VTI (Total Market): 100% equity, 100% north_america, diverse sector allocation +- VXUS (International): 100% equity, distributed across regions, diverse sectors + +You must return your response as a structured InstrumentClassification object with all fields properly populated.""" + +CLASSIFICATION_PROMPT = """Classify the following financial instrument: + +Symbol: {symbol} +Name: {name} +Type: {instrument_type} + +Provide: +1. Current price per share in USD (approximate market price as of late 2024/early 2025) +2. Accurate allocation percentages for: + - Asset classes (equity, fixed_income, real_estate, commodities, cash, alternatives) + - Regions (north_america, europe, asia, latin_america, africa, middle_east, oceania, global, international) + - Sectors (technology, healthcare, financials, consumer_discretionary, consumer_staples, industrials, materials, energy, utilities, real_estate, communication, treasury, corporate, mortgage, government_related, commodities, diversified, other) + +Remember: +- Each category must sum to exactly 100.0% +- For stocks, typically 100% in one asset class, one region, one sector +- For ETFs, distribute based on underlying holdings +- For bonds/bond funds, use fixed_income asset class and appropriate sectors (treasury/corporate/mortgage/government_related)""" + +# Pydantic models for structured data +class AllocationBreakdown(BaseModel): + """Allocation percentages that must sum to 100""" + model_config = ConfigDict(extra="forbid") + + equity: float = Field(default=0.0, ge=0, le=100, description="Equity percentage") + fixed_income: float = Field(default=0.0, ge=0, le=100, description="Fixed income percentage") + real_estate: float = Field(default=0.0, ge=0, le=100, description="Real estate percentage") + commodities: float = Field(default=0.0, ge=0, le=100, description="Commodities percentage") + cash: float = Field(default=0.0, ge=0, le=100, description="Cash percentage") + alternatives: float = Field(default=0.0, ge=0, le=100, description="Alternatives percentage") + +class RegionAllocation(BaseModel): + """Regional allocation percentages""" + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + north_america: float = Field(default=0.0, ge=0, le=100) + europe: float = Field(default=0.0, ge=0, le=100) + asia: float = Field(default=0.0, ge=0, le=100) + latin_america: float = Field(default=0.0, ge=0, le=100) + africa: float = Field(default=0.0, ge=0, le=100) + middle_east: float = Field(default=0.0, ge=0, le=100) + oceania: float = Field(default=0.0, ge=0, le=100) + global_: float = Field(default=0.0, ge=0, le=100, alias="global", description="Global or diversified") + international: float = Field(default=0.0, ge=0, le=100, description="International developed markets") + +class SectorAllocation(BaseModel): + """Sector allocation percentages""" + model_config = ConfigDict(extra="forbid") + + technology: float = Field(default=0.0, ge=0, le=100) + healthcare: float = Field(default=0.0, ge=0, le=100) + financials: float = Field(default=0.0, ge=0, le=100) + consumer_discretionary: float = Field(default=0.0, ge=0, le=100) + consumer_staples: float = Field(default=0.0, ge=0, le=100) + industrials: float = Field(default=0.0, ge=0, le=100) + materials: float = Field(default=0.0, ge=0, le=100) + energy: float = Field(default=0.0, ge=0, le=100) + utilities: float = Field(default=0.0, ge=0, le=100) + real_estate: float = Field(default=0.0, ge=0, le=100, description="Real estate sector") + communication: float = Field(default=0.0, ge=0, le=100) + treasury: float = Field(default=0.0, ge=0, le=100, description="Treasury bonds") + corporate: float = Field(default=0.0, ge=0, le=100, description="Corporate bonds") + mortgage: float = Field(default=0.0, ge=0, le=100, description="Mortgage-backed securities") + government_related: float = Field(default=0.0, ge=0, le=100, description="Government-related bonds") + commodities: float = Field(default=0.0, ge=0, le=100, description="Commodities") + diversified: float = Field(default=0.0, ge=0, le=100, description="Diversified sectors") + other: float = Field(default=0.0, ge=0, le=100, description="Other sectors") + +class InstrumentClassification(BaseModel): + """Structured output for instrument classification""" + model_config = ConfigDict(extra="forbid") + + symbol: str = Field(description="Ticker symbol of the instrument") + name: str = Field(description="Name of the instrument") + instrument_type: str = Field(description="Type: etf, stock, mutual_fund, bond_fund, etc.") + current_price: float = Field(description="Current price per share in USD", gt=0) + + # Separate allocation objects + allocation_asset_class: AllocationBreakdown = Field(description="Asset class breakdown") + allocation_regions: RegionAllocation = Field(description="Regional breakdown") + allocation_sectors: SectorAllocation = Field(description="Sector breakdown") + + @field_validator("allocation_asset_class") + def validate_asset_class_sum(cls, v: AllocationBreakdown): + total = v.equity + v.fixed_income + v.real_estate + v.commodities + v.cash + v.alternatives + if abs(total - 100.0) > 3: # Allow small floating point errors + raise ValueError(f"Asset class allocations must sum to 100.0, got {total}") + return v + + @field_validator("allocation_regions") + def validate_regions_sum(cls, v: RegionAllocation): + total = ( + v.north_america + v.europe + v.asia + v.latin_america + v.africa + + v.middle_east + v.oceania + v.global_ + v.international + ) + if abs(total - 100.0) > 3: + raise ValueError(f"Regional allocations must sum to 100.0, got {total}") + return v + + @field_validator("allocation_sectors") + def validate_sectors_sum(cls, v: SectorAllocation): + total = ( + v.technology + v.healthcare + v.financials + v.consumer_discretionary + + v.consumer_staples + v.industrials + v.materials + v.energy + v.utilities + + v.real_estate + v.communication + v.treasury + v.corporate + v.mortgage + + v.government_related + v.commodities + v.diversified + v.other + ) + if abs(total - 100.0) > 3: + raise ValueError(f"Sector allocations must sum to 100.0, got {total}") + return v + + +def classification_to_db_format(classification: InstrumentClassification) -> InstrumentCreate: + """ + Convert classification to database format. + + Args: + classification: The AI classification + + Returns: + Database-ready instrument data + """ + # Convert allocation objects to dicts + asset_class_dict = { + "equity": classification.allocation_asset_class.equity, + "fixed_income": classification.allocation_asset_class.fixed_income, + "real_estate": classification.allocation_asset_class.real_estate, + "commodities": classification.allocation_asset_class.commodities, + "cash": classification.allocation_asset_class.cash, + "alternatives": classification.allocation_asset_class.alternatives, + } + # Remove zero values + asset_class_dict = {k: v for k, v in asset_class_dict.items() if v > 0} + + regions_dict = { + "north_america": classification.allocation_regions.north_america, + "europe": classification.allocation_regions.europe, + "asia": classification.allocation_regions.asia, + "latin_america": classification.allocation_regions.latin_america, + "africa": classification.allocation_regions.africa, + "middle_east": classification.allocation_regions.middle_east, + "oceania": classification.allocation_regions.oceania, + "global": classification.allocation_regions.global_, + "international": classification.allocation_regions.international, + } + # Remove zero values + regions_dict = {k: v for k, v in regions_dict.items() if v > 0} + + sectors_dict = { + "technology": classification.allocation_sectors.technology, + "healthcare": classification.allocation_sectors.healthcare, + "financials": classification.allocation_sectors.financials, + "consumer_discretionary": classification.allocation_sectors.consumer_discretionary, + "consumer_staples": classification.allocation_sectors.consumer_staples, + "industrials": classification.allocation_sectors.industrials, + "materials": classification.allocation_sectors.materials, + "energy": classification.allocation_sectors.energy, + "utilities": classification.allocation_sectors.utilities, + "real_estate": classification.allocation_sectors.real_estate, + "communication": classification.allocation_sectors.communication, + "treasury": classification.allocation_sectors.treasury, + "corporate": classification.allocation_sectors.corporate, + "mortgage": classification.allocation_sectors.mortgage, + "government_related": classification.allocation_sectors.government_related, + "commodities": classification.allocation_sectors.commodities, + "diversified": classification.allocation_sectors.diversified, + "other": classification.allocation_sectors.other, + } + # Remove zero values + sectors_dict = {k: v for k, v in sectors_dict.items() if v > 0} + + return InstrumentCreate( + symbol=classification.symbol, + name=classification.name, + instrument_type=classification.instrument_type, + current_price=Decimal( + str(classification.current_price) + ), # Use actual price from classification + allocation_asset_class=asset_class_dict, + allocation_regions=regions_dict, + allocation_sectors=sectors_dict, + ) + + + +async def tag_instruments(instruments: List[dict]) -> List[InstrumentClassification]: + """ + Tag multiple instruments. + + Args: + instruments: List of dicts with symbol, name, and optionally instrument_type + + Returns: + List of classifications + """ + results = [] + for i, instrument in enumerate(instruments): + # Small delay between requests to avoid rate limits + if i > 0: + await asyncio.sleep(0.5) + + try: + classification = await classify_instrument( + symbol=instrument["symbol"], + name=instrument.get("name", ""), + instrument_type=instrument.get("instrument_type", "etf"), + ) + logger.info(f"Successfully classified {instrument['symbol']}") + results.append(classification) + except Exception as e: + logger.error(f"Failed to classify {instrument['symbol']}: {e}") + continue + + return results + + +async def classify_instrument( + symbol: str, name: str, instrument_type: str = "etf" +) -> InstrumentClassification: + """ + Classify a financial instrument using OpenAI Agents SDK. + + Args: + symbol: Ticker symbol + name: Instrument name + instrument_type: Type of instrument + + Returns: + Complete classification with allocations + """ + try: + + + model = BedrockModel( + model_id=model_id, + ) + + agent = Agent( + model=model, + system_prompt=TAGGER_INSTRUCTIONS + ) + + task = CLASSIFICATION_PROMPT.format( + symbol=symbol, name=name, instrument_type=instrument_type + ) + print("Generated task:", task) + + response = agent.structured_output(InstrumentClassification, task) + + # The structured_output method returns the object directly + return response + + except Exception as e: + logger.error(f"Error classifying {symbol}: {e}") + raise + + +def classification_to_dict(classification: InstrumentClassification) -> Dict[str, Any]: + """ + Convert classification to dictionary format. + + Args: + classification: The AI classification + + Returns: + Dictionary representation + """ + return { + "symbol": classification.symbol, + "name": classification.name, + "instrument_type": classification.instrument_type, + "current_price": classification.current_price, + "allocation_asset_class": classification.allocation_asset_class.model_dump(), + "allocation_regions": classification.allocation_regions.model_dump(), + "allocation_sectors": classification.allocation_sectors.model_dump(), + } + + + +async def process_instruments(instruments: List[Dict[str, str]]) -> Dict[str, Any]: + """ + Process and classify instruments asynchronously. + + Args: + instruments: List of instruments to classify + + Returns: + Processing results + """ + # Run the classification + logger.info(f"Classifying {len(instruments)} instruments") + classifications = await tag_instruments(instruments) + + # Update database with classifications + updated = [] + errors = [] + + for classification in classifications: + try: + # Convert to database format + db_instrument = classification_to_db_format(classification) + + # Check if instrument exists + existing = db.instruments.find_by_symbol(classification.symbol) + + if existing: + # Update existing instrument + update_data = db_instrument.model_dump() + # Remove symbol as it's the key + del update_data['symbol'] + + rows = db.client.update( + 'instruments', + update_data, + "symbol = :symbol", + {'symbol': classification.symbol} + ) + logger.info(f"Updated {classification.symbol} in database ({rows} rows)") + else: + # Create new instrument + db.instruments.create_instrument(db_instrument) + logger.info(f"Created {classification.symbol} in database") + + updated.append(classification.symbol) + + except Exception as e: + logger.error(f"Error updating {classification.symbol}: {e}") + errors.append({ + 'symbol': classification.symbol, + 'error': str(e) + }) + + # Prepare response (convert Pydantic models to dicts) + return { + 'tagged': len(classifications), + 'updated': updated, + 'errors': errors, + 'classifications': [ + { + 'symbol': c.symbol, + 'name': c.name, + 'type': c.instrument_type, + 'current_price': c.current_price, + 'asset_class': c.allocation_asset_class.model_dump(), + 'regions': c.allocation_regions.model_dump(), + 'sectors': c.allocation_sectors.model_dump() + } + for c in classifications + ] + } + +def tag_instrument(payload: Dict[str, Any]) -> InstrumentClassification: + """ + Tag a single instrument (synchronous version). + + Args: + payload: Dict with symbol, name, and instrument_type + + Returns: + Classification result + """ + model = BedrockModel( + model_id=model_id, + ) + + agent = Agent( + model=model, + system_prompt=TAGGER_INSTRUCTIONS + ) + + task = CLASSIFICATION_PROMPT.format( + symbol=payload["symbol"], + name=payload.get("name", ""), + instrument_type=payload.get("instrument_type", "etf") + ) + print("Tagging payload:", payload) + print("Generated task:", task) + + response = agent.structured_output(InstrumentClassification, task) + + return response + +app = BedrockAgentCoreApp() + + +@app.entrypoint +def tagger_agent(payload): + # Parse the event + try: + instruments = payload.get('instruments', []) + + if not instruments: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'No instruments provided'}) + } + + # Process all instruments in a single async context + result = asyncio.run(process_instruments(instruments)) + + return { + 'statusCode': 200, + 'body': json.dumps(result) + } + + except Exception as e: + logger.error(f"Lambda handler error: {e}") + return { + 'statusCode': 500, + 'body': json.dumps({'error': str(e)}) + } + + +if __name__ == "__main__": + app.run() + # Simple test when run directly + # async def test(): + # payload = { + # "symbol": "AAPL", + # "name": "Apple Inc", + # "instrument_type": "stock" + # } + # result = await handle_request(payload) + # print(json.dumps(result, indent=2)) + + # asyncio.run(test()) diff --git a/backend/agent_tagger/requirements.txt b/backend/agent_tagger/requirements.txt new file mode 100644 index 00000000..b5afed8c --- /dev/null +++ b/backend/agent_tagger/requirements.txt @@ -0,0 +1,12 @@ +strands-agents +strands-agents-tools +uv +boto3 +bedrock-agentcore +bedrock-agentcore-starter-toolkit +pydantic +python-dotenv +psycopg2-binary +opentelemetry-sdk +opentelemetry-instrumentation +sqlalchemy diff --git a/backend/agent_tagger/src/__init__.py b/backend/agent_tagger/src/__init__.py new file mode 100644 index 00000000..5bc75e95 --- /dev/null +++ b/backend/agent_tagger/src/__init__.py @@ -0,0 +1,51 @@ +""" +Database package for Alex Financial Planner +Provides database models, schemas, and Data API client +""" + +from .client import DataAPIClient +from .models import Database +from .schemas import ( + # Types + RegionType, + AssetClassType, + SectorType, + InstrumentType, + JobType, + JobStatus, + AccountType, + + # Create schemas (for inputs) + InstrumentCreate, + UserCreate, + AccountCreate, + PositionCreate, + JobCreate, + JobUpdate, + + # Response schemas (for outputs) + InstrumentResponse, + PortfolioAnalysis, + RebalanceRecommendation, +) + +__all__ = [ + 'Database', + 'DataAPIClient', + 'InstrumentCreate', + 'UserCreate', + 'AccountCreate', + 'PositionCreate', + 'JobCreate', + 'JobUpdate', + 'InstrumentResponse', + 'PortfolioAnalysis', + 'RebalanceRecommendation', + 'RegionType', + 'AssetClassType', + 'SectorType', + 'InstrumentType', + 'JobType', + 'JobStatus', + 'AccountType', +] \ No newline at end of file diff --git a/backend/agent_tagger/src/client.py b/backend/agent_tagger/src/client.py new file mode 100644 index 00000000..f91994e9 --- /dev/null +++ b/backend/agent_tagger/src/client.py @@ -0,0 +1,310 @@ +""" +Aurora Data API Client Wrapper +Provides a simple interface for database operations +""" + +import boto3 +import json +import os +from typing import List, Dict, Any, Optional, Tuple +from datetime import date, datetime +from decimal import Decimal +from botocore.exceptions import ClientError +import logging + +# Try to load .env file if it exists +try: + from dotenv import load_dotenv + + load_dotenv(override=True) +except ImportError: + pass # dotenv not installed, continue without it + +logger = logging.getLogger(__name__) + + +class DataAPIClient: + """Wrapper for AWS RDS Data API to simplify database operations""" + + def __init__( + self, + cluster_arn: str = None, + secret_arn: str = None, + database: str = None, + region: str = None, + ): + """ + Initialize Data API client + + Args: + cluster_arn: Aurora cluster ARN (or from env AURORA_CLUSTER_ARN) + secret_arn: Secrets Manager ARN (or from env AURORA_SECRET_ARN) + database: Database name (or from env AURORA_DATABASE) + region: AWS region (or from env AWS_REGION) + """ + self.cluster_arn = cluster_arn or os.environ.get("AURORA_CLUSTER_ARN") + self.secret_arn = secret_arn or os.environ.get("AURORA_SECRET_ARN") + self.database = database or os.environ.get("AURORA_DATABASE", "alex") + + if not self.cluster_arn or not self.secret_arn: + raise ValueError( + "Missing required Aurora configuration. " + "Set AURORA_CLUSTER_ARN and AURORA_SECRET_ARN environment variables." + ) + + self.region = os.environ.get("DEFAULT_AWS_REGION", "us-east-1") + self.client = boto3.client("rds-data", region_name=self.region) + + def execute(self, sql: str, parameters: List[Dict] = None) -> Dict: + """ + Execute a SQL statement + + Args: + sql: SQL statement to execute + parameters: Optional list of parameters for prepared statement + + Returns: + Response from Data API + """ + try: + kwargs = { + "resourceArn": self.cluster_arn, + "secretArn": self.secret_arn, + "database": self.database, + "sql": sql, + "includeResultMetadata": True, # Include column names + } + + if parameters: + kwargs["parameters"] = parameters + + response = self.client.execute_statement(**kwargs) + return response + + except ClientError as e: + logger.error(f"Database error: {e}") + raise + + def query(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """ + Execute a SELECT query and return results as list of dicts + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + List of dictionaries with column names as keys + """ + response = self.execute(sql, parameters) + + if "records" not in response: + return [] + + # Extract column names + columns = [col["name"] for col in response.get("columnMetadata", [])] + + # Convert records to dictionaries + results = [] + for record in response["records"]: + row = {} + for i, col in enumerate(columns): + value = self._extract_value(record[i]) + row[col] = value + results.append(row) + + return results + + def query_one(self, sql: str, parameters: List[Dict] = None) -> Optional[Dict]: + """ + Execute a SELECT query and return first result + + Args: + sql: SELECT statement + parameters: Optional parameters + + Returns: + Dictionary with column names as keys, or None if no results + """ + results = self.query(sql, parameters) + return results[0] if results else None + + def insert(self, table: str, data: Dict, returning: str = None) -> str: + """ + Insert a record into a table + + Args: + table: Table name + data: Dictionary of column names and values + returning: Column to return (e.g., 'id', 'clerk_user_id') + + Returns: + Value of returning column if specified + """ + columns = list(data.keys()) + placeholders = [] + + # Check if columns need type casting + for col in columns: + if isinstance(data[col], (dict, list)): + placeholders.append(f":{col}::jsonb") + elif isinstance(data[col], Decimal): + placeholders.append(f":{col}::numeric") + elif isinstance(data[col], date) and not isinstance(data[col], datetime): + placeholders.append(f":{col}::date") + elif isinstance(data[col], datetime): + placeholders.append(f":{col}::timestamp") + else: + placeholders.append(f":{col}") + + sql = f""" + INSERT INTO {table} ({", ".join(columns)}) + VALUES ({", ".join(placeholders)}) + """ + + # Add RETURNING clause if specified + if returning: + sql += f" RETURNING {returning}" + + parameters = self._build_parameters(data) + response = self.execute(sql, parameters) + + # Return value if RETURNING was used + if returning and response.get("records"): + return self._extract_value(response["records"][0][0]) + return None + + def update(self, table: str, data: Dict, where: str, where_params: Dict = None) -> int: + """ + Update records in a table + + Args: + table: Table name + data: Dictionary of columns to update + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of affected rows + """ + # Build SET clause with type casting where needed + set_parts = [] + for col, val in data.items(): + if isinstance(val, (dict, list)): + set_parts.append(f"{col} = :{col}::jsonb") + elif isinstance(val, Decimal): + set_parts.append(f"{col} = :{col}::numeric") + elif isinstance(val, date) and not isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::date") + elif isinstance(val, datetime): + set_parts.append(f"{col} = :{col}::timestamp") + else: + set_parts.append(f"{col} = :{col}") + + set_clause = ", ".join(set_parts) + + sql = f""" + UPDATE {table} + SET {set_clause} + WHERE {where} + """ + + # Combine data and where parameters + all_params = {**data, **(where_params or {})} + parameters = self._build_parameters(all_params) + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def delete(self, table: str, where: str, where_params: Dict = None) -> int: + """ + Delete records from a table + + Args: + table: Table name + where: WHERE clause (without WHERE keyword) + where_params: Parameters for WHERE clause + + Returns: + Number of deleted rows + """ + sql = f"DELETE FROM {table} WHERE {where}" + parameters = self._build_parameters(where_params) if where_params else None + + response = self.execute(sql, parameters) + return response.get("numberOfRecordsUpdated", 0) + + def begin_transaction(self) -> str: + """Begin a database transaction""" + response = self.client.begin_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, database=self.database + ) + return response["transactionId"] + + def commit_transaction(self, transaction_id: str): + """Commit a database transaction""" + self.client.commit_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def rollback_transaction(self, transaction_id: str): + """Rollback a database transaction""" + self.client.rollback_transaction( + resourceArn=self.cluster_arn, secretArn=self.secret_arn, transactionId=transaction_id + ) + + def _build_parameters(self, data: Dict) -> List[Dict]: + """Convert dictionary to Data API parameter format""" + if not data: + return [] + + parameters = [] + for key, value in data.items(): + param = {"name": key} + + if value is None: + param["value"] = {"isNull": True} + elif isinstance(value, bool): + param["value"] = {"booleanValue": value} + elif isinstance(value, int): + param["value"] = {"longValue": value} + elif isinstance(value, float): + param["value"] = {"doubleValue": value} + elif isinstance(value, Decimal): + param["value"] = {"stringValue": str(value)} + elif isinstance(value, (date, datetime)): + param["value"] = {"stringValue": value.isoformat()} + elif isinstance(value, dict): + param["value"] = {"stringValue": json.dumps(value)} + elif isinstance(value, list): + param["value"] = {"stringValue": json.dumps(value)} + else: + param["value"] = {"stringValue": str(value)} + + parameters.append(param) + + return parameters + + def _extract_value(self, field: Dict) -> Any: + """Extract value from Data API field response""" + if field.get("isNull"): + return None + elif "booleanValue" in field: + return field["booleanValue"] + elif "longValue" in field: + return field["longValue"] + elif "doubleValue" in field: + return field["doubleValue"] + elif "stringValue" in field: + value = field["stringValue"] + # Try to parse JSON if it looks like JSON + if value and value[0] in ["{", "["]: + try: + return json.loads(value) + except json.JSONDecodeError: + pass + return value + elif "blobValue" in field: + return field["blobValue"] + else: + return None diff --git a/backend/agent_tagger/src/models.py b/backend/agent_tagger/src/models.py new file mode 100644 index 00000000..903e3594 --- /dev/null +++ b/backend/agent_tagger/src/models.py @@ -0,0 +1,320 @@ +""" +Database models and query builders +""" + +from typing import Dict, List, Optional, Any +from datetime import datetime, date +from decimal import Decimal +from .client import DataAPIClient +from .schemas import ( + InstrumentCreate, UserCreate, AccountCreate, + PositionCreate, JobCreate, JobUpdate +) + + +class BaseModel: + """Base class for database models""" + + table_name = None + + def __init__(self, db: DataAPIClient): + self.db = db + if not self.table_name: + raise ValueError("table_name must be defined") + + def find_by_id(self, id: Any) -> Optional[Dict]: + """Find a record by ID""" + sql = f"SELECT * FROM {self.table_name} WHERE id = :id::uuid" + return self.db.query_one(sql, [{'name': 'id', 'value': {'stringValue': str(id)}}]) + + def find_all(self, limit: int = 100, offset: int = 0) -> List[Dict]: + """Find all records with pagination""" + sql = f"SELECT * FROM {self.table_name} LIMIT :limit OFFSET :offset" + params = [ + {'name': 'limit', 'value': {'longValue': limit}}, + {'name': 'offset', 'value': {'longValue': offset}} + ] + return self.db.query(sql, params) + + def create(self, data: Dict, returning: str = 'id') -> str: + """Create a new record""" + return self.db.insert(self.table_name, data, returning=returning) + + def update(self, id: Any, data: Dict) -> int: + """Update a record by ID""" + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': str(id)}) + + def delete(self, id: Any) -> int: + """Delete a record by ID""" + return self.db.delete(self.table_name, "id = :id::uuid", {'id': str(id)}) + + +class Users(BaseModel): + """Users table operations""" + table_name = 'users' + + def find_by_clerk_id(self, clerk_user_id: str) -> Optional[Dict]: + """Find user by Clerk ID""" + sql = f"SELECT * FROM {self.table_name} WHERE clerk_user_id = :clerk_id" + params = [{'name': 'clerk_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query_one(sql, params) + + def create_user(self, clerk_user_id: str, display_name: str = None, + years_until_retirement: int = None, + target_retirement_income: Decimal = None) -> str: + """Create a new user""" + data = { + 'clerk_user_id': clerk_user_id, + 'display_name': display_name, + 'years_until_retirement': years_until_retirement, + 'target_retirement_income': target_retirement_income + } + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + return self.db.insert(self.table_name, data, returning='clerk_user_id') + + +class Instruments(BaseModel): + """Instruments table operations""" + table_name = 'instruments' + + def find_all(self, limit: int = None, offset: int = 0) -> List[Dict]: + """Find all instruments - no limit by default for autocomplete""" + sql = f"SELECT * FROM {self.table_name} ORDER BY symbol" + return self.db.query(sql, []) + + def find_by_symbol(self, symbol: str) -> Optional[Dict]: + """Find instrument by symbol""" + sql = f"SELECT * FROM {self.table_name} WHERE symbol = :symbol" + params = [{'name': 'symbol', 'value': {'stringValue': symbol}}] + return self.db.query_one(sql, params) + + def create_instrument(self, instrument: InstrumentCreate) -> str: + """Create a new instrument with validation""" + # Validate using Pydantic + validated = instrument.model_dump() + + # Convert allocations to JSON strings for storage + data = { + 'symbol': validated['symbol'], + 'name': validated['name'], + 'instrument_type': validated['instrument_type'], + 'allocation_regions': validated['allocation_regions'], + 'allocation_sectors': validated['allocation_sectors'], + 'allocation_asset_class': validated['allocation_asset_class'] + } + + return self.db.insert(self.table_name, data, returning='symbol') + + def find_by_type(self, instrument_type: str) -> List[Dict]: + """Find all instruments of a specific type""" + sql = f"SELECT * FROM {self.table_name} WHERE instrument_type = :type ORDER BY symbol" + params = [{'name': 'type', 'value': {'stringValue': instrument_type}}] + return self.db.query(sql, params) + + def search(self, query: str) -> List[Dict]: + """Search instruments by symbol or name""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE LOWER(symbol) LIKE LOWER(:query) + OR LOWER(name) LIKE LOWER(:query) + ORDER BY symbol + LIMIT 20 + """ + params = [{'name': 'query', 'value': {'stringValue': f'%{query}%'}}] + return self.db.query(sql, params) + + +class Accounts(BaseModel): + """Accounts table operations""" + table_name = 'accounts' + + def find_by_user(self, clerk_user_id: str) -> List[Dict]: + """Find all accounts for a user""" + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + """ + params = [{'name': 'user_id', 'value': {'stringValue': clerk_user_id}}] + return self.db.query(sql, params) + + def create_account(self, clerk_user_id: str, account_name: str, + account_purpose: str = None, cash_balance: Decimal = Decimal('0'), + cash_interest: Decimal = Decimal('0')) -> str: + """Create a new account""" + data = { + 'clerk_user_id': clerk_user_id, + 'account_name': account_name, + 'account_purpose': account_purpose, + 'cash_balance': cash_balance, + 'cash_interest': cash_interest + } + return self.db.insert(self.table_name, data, returning='id') + + +class Positions(BaseModel): + """Positions table operations""" + table_name = 'positions' + + def find_by_account(self, account_id: str) -> List[Dict]: + """Find all positions in an account""" + sql = f""" + SELECT p.*, i.name as instrument_name, i.instrument_type, i.current_price + FROM {self.table_name} p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + ORDER BY p.symbol + """ + params = [{'name': 'account_id', 'value': {'stringValue': account_id}}] + return self.db.query(sql, params) + + def get_portfolio_value(self, account_id: str) -> Dict: + """Calculate total portfolio value using current prices from instruments table""" + sql = """ + SELECT + COUNT(DISTINCT p.symbol) as num_positions, + SUM(p.quantity * i.current_price) as total_value, + SUM(p.quantity) as total_shares + FROM positions p + JOIN instruments i ON p.symbol = i.symbol + WHERE p.account_id = :account_id::uuid + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}} + ] + result = self.db.query_one(sql, params) + if result: + return { + 'num_positions': result.get('num_positions', 0), + 'total_value': float(result.get('total_value', 0)) if result.get('total_value') else 0, + 'total_shares': float(result.get('total_shares', 0)) if result.get('total_shares') else 0 + } + return {'num_positions': 0, 'total_value': 0, 'total_shares': 0} + + def add_position(self, account_id: str, symbol: str, quantity: Decimal) -> str: + """Add or update a position""" + # Use UPSERT to handle existing positions + sql = """ + INSERT INTO positions (account_id, symbol, quantity, as_of_date) + VALUES (:account_id::uuid, :symbol, :quantity::numeric, :as_of_date::date) + ON CONFLICT (account_id, symbol) + DO UPDATE SET + quantity = EXCLUDED.quantity, + as_of_date = EXCLUDED.as_of_date, + updated_at = NOW() + RETURNING id + """ + params = [ + {'name': 'account_id', 'value': {'stringValue': account_id}}, + {'name': 'symbol', 'value': {'stringValue': symbol}}, + {'name': 'quantity', 'value': {'stringValue': str(quantity)}}, + {'name': 'as_of_date', 'value': {'stringValue': date.today().isoformat()}} + ] + response = self.db.execute(sql, params) + if response.get('records'): + return response['records'][0][0].get('stringValue') + return None + + +class Jobs(BaseModel): + """Jobs table operations""" + table_name = 'jobs' + + def create_job(self, clerk_user_id: str, job_type: str, + request_payload: Dict = None) -> str: + """Create a new job""" + data = { + 'clerk_user_id': clerk_user_id, + 'job_type': job_type, + 'status': 'pending', + 'request_payload': request_payload + } + return self.db.insert(self.table_name, data, returning='id') + + def update_status(self, job_id: str, status: str, error_message: str = None) -> int: + """Update job status""" + data = {'status': status} + + if status == 'running': + data['started_at'] = datetime.utcnow() + elif status in ['completed', 'failed', 'max_tokens_exceeded']: + data['completed_at'] = datetime.utcnow() + + if error_message: + data['error_message'] = error_message + + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_report(self, job_id: str, report_payload: Dict) -> int: + """Update job with Reporter agent's analysis""" + data = {'report_payload': report_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_charts(self, job_id: str, charts_payload: Dict) -> int: + """Update job with Charter agent's visualization data""" + data = {'charts_payload': charts_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_retirement(self, job_id: str, retirement_payload: Dict) -> int: + """Update job with Retirement agent's projections""" + data = {'retirement_payload': retirement_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def update_summary(self, job_id: str, summary_payload: Dict) -> int: + """Update job with Planner's final summary""" + data = {'summary_payload': summary_payload} + return self.db.update(self.table_name, data, "id = :id::uuid", {'id': job_id}) + + def find_by_user(self, clerk_user_id: str, status: str = None, + limit: int = 20) -> List[Dict]: + """Find jobs for a user""" + if status: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id AND status = :status + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'status', 'value': {'stringValue': status}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + else: + sql = f""" + SELECT * FROM {self.table_name} + WHERE clerk_user_id = :user_id + ORDER BY created_at DESC + LIMIT :limit + """ + params = [ + {'name': 'user_id', 'value': {'stringValue': clerk_user_id}}, + {'name': 'limit', 'value': {'longValue': limit}} + ] + + return self.db.query(sql, params) + + +class Database: + """Main database interface providing access to all models""" + + def __init__(self, cluster_arn: str = None, secret_arn: str = None, + database: str = None, region: str = None): + """Initialize database with all model classes""" + self.client = DataAPIClient(cluster_arn, secret_arn, database, region) + + # Initialize all models + self.users = Users(self.client) + self.instruments = Instruments(self.client) + self.accounts = Accounts(self.client) + self.positions = Positions(self.client) + self.jobs = Jobs(self.client) + + def execute_raw(self, sql: str, parameters: List[Dict] = None) -> Dict: + """Execute raw SQL for complex queries""" + return self.client.execute(sql, parameters) + + def query_raw(self, sql: str, parameters: List[Dict] = None) -> List[Dict]: + """Execute raw SELECT query""" + return self.client.query(sql, parameters) \ No newline at end of file diff --git a/backend/agent_tagger/src/schemas.py b/backend/agent_tagger/src/schemas.py new file mode 100644 index 00000000..44f16514 --- /dev/null +++ b/backend/agent_tagger/src/schemas.py @@ -0,0 +1,284 @@ +""" +Pydantic schemas for data validation and LLM tool interfaces +These models serve as both database validation and LLM structured output schemas +""" + +from typing import Dict, Literal, Optional, List +from pydantic import BaseModel, Field, field_validator +from decimal import Decimal +from datetime import date, datetime + + +# Define allowed values as Literals for LLM compatibility +RegionType = Literal[ + "north_america", + "europe", + "asia", + "latin_america", + "africa", + "middle_east", + "oceania", + "global", + "international", # For mixed non-US +] + +AssetClassType = Literal[ + "equity", "fixed_income", "real_estate", "commodities", "cash", "alternatives" +] + +SectorType = Literal[ + "technology", + "healthcare", + "financials", + "consumer_discretionary", + "consumer_staples", + "industrials", + "energy", + "materials", + "utilities", + "real_estate", + "communication", + "treasury", + "corporate", + "mortgage", + "government_related", + "commodities", + "diversified", + "other", +] + +InstrumentType = Literal["etf", "mutual_fund", "stock", "bond", "bond_fund", "commodity", "reit"] + +JobType = Literal[ + "portfolio_analysis", + "rebalance_recommendation", + "retirement_projection", + "risk_assessment", + "tax_optimization", + "instrument_research", +] + +JobStatus = Literal["pending", "running", "completed", "failed", "max_tokens_exceeded"] + +AccountType = Literal[ + "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" +] + + +class AllocationDict(BaseModel): + """Base class for allocation dictionaries ensuring they sum to 100""" + + @field_validator("*", mode="after") + def validate_sum(cls, v, info): + """Ensure allocation percentages sum to 100""" + if isinstance(v, dict): + total = sum(v.values()) + if abs(total - 100) > 3: # Allow small floating point errors + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class RegionAllocation(BaseModel): + """Geographic allocation of an instrument""" + + allocations: Dict[RegionType, float] = Field( + description="Percentage allocation by geographic region. Must sum to 100.", + example={"north_america": 60, "europe": 25, "asia": 15}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Region allocations must sum to 100, got {total}") + return v + + +class AssetClassAllocation(BaseModel): + """Asset class allocation of an instrument""" + + allocations: Dict[AssetClassType, float] = Field( + description="Percentage allocation by asset class. Must sum to 100.", + example={"equity": 80, "fixed_income": 20}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Asset class allocations must sum to 100, got {total}") + return v + + +class SectorAllocation(BaseModel): + """Sector allocation of an instrument""" + + allocations: Dict[SectorType, float] = Field( + description="Percentage allocation by market sector. Must sum to 100.", + example={"technology": 30, "healthcare": 25, "financials": 20, "other": 25}, + ) + + @field_validator("allocations") + def validate_sum(cls, v): + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Sector allocations must sum to 100, got {total}") + return v + + +class InstrumentCreate(BaseModel): + """Schema for creating a new instrument - suitable for LLM tool input""" + + symbol: str = Field( + description="The ticker symbol of the instrument (e.g., 'SPY', 'BND')", + min_length=1, + max_length=20, + ) + name: str = Field(description="Full name of the instrument", min_length=1, max_length=255) + instrument_type: InstrumentType = Field(description="The type of financial instrument") + current_price: Optional[Decimal] = Field( + None, + description="Current price of the instrument for portfolio calculations", + ge=0, + le=999999, + ) + allocation_regions: Dict[RegionType, float] = Field( + description="Geographic allocation percentages. Must sum to 100.", + example={"north_america": 100}, + ) + allocation_sectors: Dict[SectorType, float] = Field( + description="Sector allocation percentages. Must sum to 100.", + example={"technology": 40, "healthcare": 30, "financials": 30}, + ) + allocation_asset_class: Dict[AssetClassType, float] = Field( + description="Asset class allocation percentages. Must sum to 100.", example={"equity": 100} + ) + + @field_validator("allocation_regions", "allocation_sectors", "allocation_asset_class") + def validate_allocations(cls, v): + """Ensure all allocations sum to 100""" + if not v: + raise ValueError("Allocation cannot be empty") + total = sum(v.values()) + if abs(total - 100) > 3: + raise ValueError(f"Allocations must sum to 100, got {total}") + return v + + +class InstrumentResponse(InstrumentCreate): + """Schema for instrument responses from database""" + + created_at: datetime + updated_at: datetime + + +class UserCreate(BaseModel): + """Schema for creating a user - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="Unique identifier from Clerk authentication system") + display_name: Optional[str] = Field(None, description="User's display name", max_length=255) + years_until_retirement: Optional[int] = Field( + None, description="Number of years until the user plans to retire", ge=0, le=100 + ) + target_retirement_income: Optional[Decimal] = Field( + None, description="Annual income goal in retirement (in dollars)", ge=0, decimal_places=2 + ) + asset_class_targets: Optional[Dict[AssetClassType, float]] = Field( + default={"equity": 70, "fixed_income": 30}, + description="Target allocation percentages for rebalancing. Must sum to 100.", + ) + region_targets: Optional[Dict[RegionType, float]] = Field( + default={"north_america": 50, "international": 50}, + description="Target geographic allocation for rebalancing. Must sum to 100.", + ) + + +class AccountCreate(BaseModel): + """Schema for creating an account - suitable for LLM tool input""" + + account_name: str = Field( + description="Name of the account (e.g., '401k', 'Roth IRA')", min_length=1, max_length=255 + ) + account_purpose: Optional[str] = Field(None, description="Purpose or goal of this account") + cash_balance: Decimal = Field( + default=Decimal("0"), + description="Uninvested cash balance in the account", + ge=0, + decimal_places=2, + ) + cash_interest: Decimal = Field( + default=Decimal("0"), + description="Annual interest rate on cash (e.g., 0.045 for 4.5%)", + ge=0, + le=1, + decimal_places=4, + ) + + +class PositionCreate(BaseModel): + """Schema for creating a position - suitable for LLM tool input""" + + account_id: str = Field(description="UUID of the account holding this position") + symbol: str = Field(description="Ticker symbol of the instrument", min_length=1, max_length=20) + quantity: Decimal = Field( + description="Number of shares (supports fractional shares)", gt=0, decimal_places=8 + ) + as_of_date: Optional[date] = Field( + default_factory=date.today, description="Date of this position snapshot" + ) + + +class JobCreate(BaseModel): + """Schema for creating a job - suitable for LLM tool input""" + + clerk_user_id: str = Field(description="User requesting this job") + job_type: JobType = Field(description="Type of analysis or operation to perform") + request_payload: Optional[Dict] = Field(None, description="Input parameters for the job") + + +class JobUpdate(BaseModel): + """Schema for updating job status - suitable for LLM tool output""" + + status: JobStatus = Field(description="Current status of the job") + result_payload: Optional[Dict] = Field(None, description="Results of the completed job") + error_message: Optional[str] = Field(None, description="Error details if job failed") + + +class PortfolioAnalysis(BaseModel): + """Schema for portfolio analysis results - LLM structured output""" + + total_value: Decimal = Field(description="Total portfolio value in dollars", decimal_places=2) + asset_allocation: Dict[AssetClassType, float] = Field( + description="Current asset class allocation percentages" + ) + region_allocation: Dict[RegionType, float] = Field( + description="Current geographic allocation percentages" + ) + sector_allocation: Dict[SectorType, float] = Field( + description="Current sector allocation percentages" + ) + risk_score: int = Field( + description="Risk score from 1 (conservative) to 10 (aggressive)", ge=1, le=10 + ) + recommendations: List[str] = Field( + description="List of actionable recommendations for the portfolio" + ) + + +class RebalanceRecommendation(BaseModel): + """Schema for rebalancing recommendations - LLM structured output""" + + current_allocation: Dict[str, float] = Field( + description="Current allocation by instrument symbol" + ) + target_allocation: Dict[str, float] = Field( + description="Recommended target allocation by symbol" + ) + trades: List[Dict] = Field( + description="List of trades needed to rebalance", + example=[ + {"symbol": "SPY", "action": "sell", "quantity": 10}, + {"symbol": "BND", "action": "buy", "quantity": 50}, + ], + ) + rationale: str = Field(description="Explanation of why these changes are recommended") diff --git a/backend/agent_tagger/test_simple.py b/backend/agent_tagger/test_simple.py new file mode 100644 index 00000000..1a6aedc2 --- /dev/null +++ b/backend/agent_tagger/test_simple.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Simple test for Tagger agent +""" + +import asyncio +import json +from dotenv import load_dotenv + +load_dotenv(override=True) + +from agent import tagger_agent + +def test_agent(): + """Test the Bedrock ESG Agent locally""" + + test_payload = { + "instruments": [ + {"symbol": "VTI", "name": "Vanguard Total Stock Market ETF"}, + {"symbol": "ARKK", "name": "ARK Innovation ETF"}, + {"symbol": "SOFI", "name": "SoFi Technologies Inc"}, + {"symbol": "TSLA", "name": "Tesla Inc"} + ] + } + + print("Testing Bedrock Agent...") + print("=" * 60) + + # Directly invoke the entrypoint function + result = tagger_agent(test_payload) + print(f"Status Code: {result['statusCode']}") + + if result['statusCode'] == 200: + body = json.loads(result['body']) + print(f"Tagged: {body.get('tagged', 0)} instruments") + print(f"Updated: {body.get('updated', [])}") + if body.get('classifications'): + for c in body['classifications']: + print(f" {c['symbol']}: {c['type']}") + else: + print(f"Error: {result['body']}") + + print("=" * 60) + +if __name__ == "__main__": + test_agent() \ No newline at end of file diff --git a/backend/agent_tagger/utils.py b/backend/agent_tagger/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/agent_tagger/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/api/main.pysleep b/backend/api/main.pysleep new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/package_docker.py_ b/backend/api/package_docker.py_ new file mode 100644 index 00000000..275ba08a --- /dev/null +++ b/backend/api/package_docker.py_ @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Package the FastAPI API for Lambda deployment using Docker. +This ensures binary compatibility with Lambda's runtime environment. +""" + +import os +import sys +import shutil +import subprocess +from pathlib import Path +import tempfile +import zipfile + +def run_command(cmd, cwd=None): + """Run a shell command and handle errors.""" + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Error: {result.stderr}") + sys.exit(1) + return result.stdout + +def main(): + # Get the API directory + api_dir = Path(__file__).parent.absolute() + backend_dir = api_dir.parent + project_root = backend_dir.parent + + print(f"API directory: {api_dir}") + print(f"Backend directory: {backend_dir}") + + # Check if Docker is running + try: + run_command(["docker", "info"]) + except Exception as e: + print("Error: Docker is not running or not installed") + print("Please ensure Docker Desktop is running and try again") + sys.exit(1) + + # Create temp directory for packaging + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + package_dir = temp_path / "package" + package_dir.mkdir() + + print(f"Packaging in: {package_dir}") + + # Copy API code + api_package = package_dir / "api" + shutil.copytree(api_dir, api_package, ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", ".env*", "*.zip", "package_docker.py", "test_*.py" + )) + + # Copy lambda_handler.py to root level for Lambda to find it + shutil.copy2(api_dir / "lambda_handler.py", package_dir / "lambda_handler.py") + + # Copy database package + database_src = backend_dir / "database" / "src" + database_dst = package_dir / "src" + if database_src.exists(): + shutil.copytree(database_src, database_dst, ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc" + )) + print(f"Copied database package from {database_src}") + + # Verify database package was copied + if (database_dst / "__init__.py").exists(): + print("✅ Database package copied successfully") + else: + print("⚠️ Warning: Database package may not have been copied correctly") + else: + print(f"❌ Error: Database package not found at {database_src}") + sys.exit(1) + + # Create requirements.txt from pyproject.toml + requirements_file = package_dir / "requirements.txt" + with open(requirements_file, "w") as f: + # Core dependencies + f.write("fastapi>=0.116.0\n") + f.write("uvicorn>=0.35.0\n") + f.write("mangum>=0.19.0\n") + f.write("boto3>=1.26.0\n") + f.write("fastapi-clerk-auth>=0.0.7\n") + f.write("pydantic>=2.0.0\n") + f.write("python-dotenv>=1.0.0\n") + f.write("python-jose>=3.5.0\n") + f.write("httpx>=0.28.0\n") + # Database dependencies + f.write("sqlalchemy>=2.0.0\n") + f.write("psycopg2-binary>=2.9.0\n") + + # Create Dockerfile + dockerfile_content = """ +FROM public.ecr.aws/lambda/python:3.12 + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -t /var/task + +# Copy application code +COPY . /var/task/ + +# Set the handler +CMD ["api.main.handler"] +""" + + dockerfile = package_dir / "Dockerfile" + with open(dockerfile, "w") as f: + f.write(dockerfile_content) + + # Build Docker image for x86_64 architecture (Lambda runtime) + print("Building Docker image for x86_64 architecture...") + run_command([ + "docker", "build", + "--platform", "linux/amd64", + "-t", "alex-api-packager", + "." + ], cwd=package_dir) + + # Create container and extract files + print("Extracting Lambda package...") + container_name = "alex-api-extract" + + # Remove container if it exists + run_command(["docker", "rm", "-f", container_name], cwd=package_dir) + + # Create container + run_command([ + "docker", "create", + "--name", container_name, + "alex-api-packager" + ], cwd=package_dir) + + # Extract /var/task contents + extract_dir = temp_path / "lambda" + extract_dir.mkdir() + + run_command([ + "docker", "cp", + f"{container_name}:/var/task/.", + str(extract_dir) + ]) + + # Clean up container + run_command(["docker", "rm", "-f", container_name]) + + # Create the final zip + zip_path = api_dir / "api_lambda.zip" + print(f"Creating zip file: {zip_path}") + + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(extract_dir): + # Skip __pycache__ directories + dirs[:] = [d for d in dirs if d != '__pycache__'] + + for file in files: + # Skip .pyc files + if file.endswith('.pyc'): + continue + + file_path = Path(root) / file + arcname = file_path.relative_to(extract_dir) + zipf.write(file_path, arcname) + + # Get file size + size_mb = zip_path.stat().st_size / (1024 * 1024) + print(f"✅ Lambda package created: {zip_path} ({size_mb:.2f} MB)") + + # Verify the package + print("\nPackage contents (first 20 files):") + with zipfile.ZipFile(zip_path, 'r') as zipf: + files = zipf.namelist()[:20] + for f in files: + print(f" - {f}") + if len(zipf.namelist()) > 20: + print(f" ... and {len(zipf.namelist()) - 20} more files") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/database/src/models.py b/backend/database/src/models.py index 8bf5621c..903e3594 100644 --- a/backend/database/src/models.py +++ b/backend/database/src/models.py @@ -238,7 +238,7 @@ def update_status(self, job_id: str, status: str, error_message: str = None) -> if status == 'running': data['started_at'] = datetime.utcnow() - elif status in ['completed', 'failed']: + elif status in ['completed', 'failed', 'max_tokens_exceeded']: data['completed_at'] = datetime.utcnow() if error_message: diff --git a/backend/database/src/schemas.py b/backend/database/src/schemas.py index 30952398..3ddb2c9c 100644 --- a/backend/database/src/schemas.py +++ b/backend/database/src/schemas.py @@ -58,7 +58,7 @@ "instrument_research", ] -JobStatus = Literal["pending", "running", "completed", "failed"] +JobStatus = Literal["pending", "running", "completed", "failed","max_tokens_exceeded"] AccountType = Literal[ "401k", "roth_ira", "traditional_ira", "taxable", "529", "hsa", "pension", "other" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index db25a81d..ee3fb05e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,17 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "alex-database", + "bedrock-agentcore>=1.0.3", + "bedrock-agentcore-starter-toolkit>=0.1.26", "boto3>=1.40.29", "langfuse>=3.3.4", + "nest-asyncio>=1.6.0", "openai-agents>=0.3.0", + "playwright>=1.55.0", "pydantic-ai>=1.0.6", "python-dotenv>=1.1.1", + "strands-agents>=1.13.0", + "strands-agents-tools>=0.2.12", ] [tool.uv.workspace] diff --git a/backend/sqs_orchestrator/lambda_handler.py b/backend/sqs_orchestrator/lambda_handler.py new file mode 100644 index 00000000..a49fe8ff --- /dev/null +++ b/backend/sqs_orchestrator/lambda_handler.py @@ -0,0 +1,210 @@ +""" +SQS to AgentCore Bridge Lambda Function + +This Lambda function receives SQS messages and invokes the AgentCore planner agent. +""" + +import json +import logging +import boto3 +import asyncio +import os +from typing import Dict, Any + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Initialize boto3 clients +ssm = boto3.client('ssm') + +def get_planner_agent_arn() -> str: + """Get the planner agent ARN from SSM Parameter Store.""" + try: + response = ssm.get_parameter(Name='/agents/planner_agent_arn') + return response['Parameter']['Value'] + except Exception as e: + logger.error(f"Failed to get planner agent ARN: {e}") + raise + + +async def invoke_agent_with_boto3(agent_runtime_arn: str, session_id: str, payload: dict) -> str: + """Invoke an AgentCore agent runtime with a JSON payload. + + Uses the bedrock-agentcore InvokeAgentRuntime API which expects: + - agentRuntimeArn: the runtime ARN + - payload: JSON string passed through to the agent's @app.entrypoint + """ + region = os.environ.get("DEFAULT_AWS_REGION") or os.environ.get("AWS_REGION") + client = boto3.client('bedrock-agentcore', region_name=region) if region else boto3.client('bedrock-agentcore') + + try: + # Always include session id for tracing if provided + if session_id and 'session_id' not in payload: + payload = {**payload, 'session_id': session_id} + + resp = client.invoke_agent_runtime( + agentRuntimeArn=agent_runtime_arn, + payload=json.dumps(payload) + ) + + # Handle StreamingBody response properly + # Check for 'response' field first (bedrock-agentcore format), then 'body' field + response_body = None + if isinstance(resp, dict): + if 'response' in resp: + response_body = resp['response'] + elif 'body' in resp: + response_body = resp['body'] + + if response_body: + # Check if body is a StreamingBody (from botocore.response) + if hasattr(response_body, 'read'): + # Read the streaming body + body_content = response_body.read() + if isinstance(body_content, bytes): + body_content = body_content.decode('utf-8') + logger.info(f"AgentCore response body: {body_content}") + return body_content + elif isinstance(response_body, (bytes, bytearray)): + body_content = response_body.decode('utf-8') + logger.info(f"AgentCore response body: {body_content}") + return body_content + elif isinstance(response_body, str): + logger.info(f"AgentCore response body: {response_body}") + return response_body + else: + # Try to JSON serialize other response types + logger.info(f"AgentCore response body type: {type(response_body)}") + return json.dumps(response_body, default=str) + + # If no body field, try to handle the whole response + logger.info(f"AgentCore response type: {type(resp)}, content: {resp}") + return json.dumps(resp, default=str) + + except Exception as e: + logger.error(f"Error invoking agent runtime {agent_runtime_arn}: {e}") + return f"Error invoking agent: {str(e)}" + +def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: + """ + Handle SQS messages and invoke AgentCore planner agent. + + Expected SQS message body: {"job_id": "uuid"} + """ + try: + logger.info(f"SQS Orchestrator invoked with event: {json.dumps(event)}") + + # Get planner agent ARN + planner_arn = get_planner_agent_arn() + logger.info(f"Using planner agent ARN: {planner_arn}") + + successful_jobs = [] + failed_jobs = [] + + # Process each SQS record + for record in event.get('Records', []): + try: + # Parse job_id from SQS message + message_body = record['body'] + logger.info(f"Processing message: {message_body}") + + # Parse JSON if needed + if isinstance(message_body, str): + try: + body_data = json.loads(message_body) + job_id = body_data.get('job_id', message_body) + except json.JSONDecodeError: + job_id = message_body + else: + job_id = message_body + + logger.info(f"Extracted job_id: {job_id}") + + # Create payload for AgentCore + payload = { + "job_id": job_id + } + + logger.info(f"Invoking planner agent for job: {job_id} with payload: {payload}") + + # Use asyncio to call the async function + response = asyncio.run(invoke_agent_with_boto3(planner_arn, job_id, payload)) + + # Check if response indicates max_tokens_exceeded + try: + if isinstance(response, str): + response_data = json.loads(response) + if response_data.get('max_tokens_exceeded'): + logger.warning(f"Planner agent reached max tokens for job: {job_id}") + logger.info(f"Max tokens response: {response_data.get('message', 'No message')}") + successful_jobs.append({ + 'job_id': job_id, + 'status': 'max_tokens_exceeded', + 'message': response_data.get('message', 'Agent reached max tokens limit') + }) + continue + except (json.JSONDecodeError, TypeError): + # Response is not JSON or not a dict, proceed normally + pass + + logger.info(f"Planner agent invoked successfully for job: {job_id}") + logger.info(f"Response: {response}") + successful_jobs.append(job_id) + + except Exception as e: + error_message = str(e) + # Check if this is a max_tokens related error + if 'max_tokens' in error_message.lower() or 'maxtokensreachedException' in error_message: + logger.warning(f"Max tokens reached for record {record.get('messageId', 'unknown')}: {e}") + successful_jobs.append({ + 'job_id': job_id if 'job_id' in locals() else 'unknown', + 'status': 'max_tokens_exceeded', + 'message': f'Agent reached max tokens limit: {error_message}' + }) + else: + logger.error(f"Failed to process record {record.get('messageId', 'unknown')}: {e}") + failed_jobs.append({ + 'messageId': record.get('messageId', 'unknown'), + 'error': error_message + }) + + # Return results + result = { + 'statusCode': 200 if not failed_jobs else 207, # 207 = Multi-Status + 'body': json.dumps({ + 'successful_jobs': successful_jobs, + 'failed_jobs': failed_jobs, + 'total_processed': len(event.get('Records', [])), + 'success_count': len(successful_jobs), + 'failure_count': len(failed_jobs) + }) + } + + logger.info(f"SQS Orchestrator completed: {result}") + return result + + except Exception as e: + logger.error(f"Fatal error in SQS Orchestrator: {e}", exc_info=True) + return { + 'statusCode': 500, + 'body': json.dumps({ + 'error': str(e), + 'successful_jobs': [], + 'failed_jobs': [] + }) + } + +# For local testing +if __name__ == "__main__": + # Test with a sample SQS event + test_event = { + "Records": [ + { + "messageId": "test-message-1", + "body": '{"job_id": "test-job-uuid-123"}' + } + ] + } + + result = lambda_handler(test_event, None) + print(json.dumps(result, indent=2)) \ No newline at end of file diff --git a/backend/test_api_response.py b/backend/test_api_response.py new file mode 100644 index 00000000..7dec7ae2 --- /dev/null +++ b/backend/test_api_response.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Test API response format for chart data""" + +import os +import sys +import json +import requests +from pathlib import Path + +from database.src import Database + +def test_api_response(): + """Test how the API returns chart data""" + + # Load environment + from dotenv import load_dotenv + load_dotenv() + + # First, get a job with chart data from database + db = Database() + + sql = """ + SELECT id, clerk_user_id, charts_payload + FROM jobs + WHERE charts_payload IS NOT NULL + LIMIT 1 + """ + + result = db.execute_raw(sql, {}) + + if not result.get('records'): + print("❌ No jobs with chart data found") + return + + job_data = result['records'][0] + job_id = job_data[0]['stringValue'] + user_id = job_data[1]['stringValue'] + charts_raw = job_data[2] + + print(f"✅ Found job {job_id} for user {user_id}") + print(f"Charts data type: {type(charts_raw)}") + + if isinstance(charts_raw, str): + print("❌ Charts data is still a string, not parsed") + print(f"Sample: {charts_raw[:200]}...") + elif isinstance(charts_raw, dict): + print("✅ Charts data is properly parsed as dict") + print(f"Chart keys: {list(charts_raw.keys())}") + else: + print(f"⚠️ Unexpected charts data type: {type(charts_raw)}") + + # Now test the API endpoint + api_base_url = os.getenv('API_BASE_URL', 'http://localhost:3000') + + # Assuming we need to test locally, let's just check the database response format + # for now since the API might require authentication + + print("\n📋 Database response format analysis:") + print(f"Job ID field type: {type(job_data[0])}") + print(f"User ID field type: {type(job_data[1])}") + print(f"Charts field type: {type(job_data[2])}") + + # Test using the Jobs model directly + job_from_model = db.jobs.find_by_id(job_id) + print(f"\n📝 Jobs model response:") + print(f"Type: {type(job_from_model)}") + if job_from_model: + charts_from_model = job_from_model.get('charts_payload') + print(f"Charts payload type: {type(charts_from_model)}") + if isinstance(charts_from_model, dict): + print(f"✅ Model correctly parses JSON - keys: {list(charts_from_model.keys())}") + else: + print(f"❌ Model doesn't parse JSON - type: {type(charts_from_model)}") + +if __name__ == "__main__": + test_api_response() \ No newline at end of file diff --git a/backend/test_full.py b/backend/test_full_agentcore.py similarity index 100% rename from backend/test_full.py rename to backend/test_full_agentcore.py diff --git a/backend/test_simple_agentcore.py b/backend/test_simple_agentcore.py new file mode 100644 index 00000000..4cf6eb89 --- /dev/null +++ b/backend/test_simple_agentcore.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Test all agents by running their individual test_simple.py files in their own directories. +This ensures each agent runs with its own dependencies and environment. +""" + +import os +import subprocess +import sys +from pathlib import Path + +def run_command(cmd, cwd): + """Run a command and capture output.""" + print(f"Running in {cwd}: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + return result.returncode == 0, result.stdout, result.stderr + +def test_agent(agent_name, test_file="test_simple.py"): + """Test an individual agent in its directory.""" + backend_dir = Path(__file__).parent + agent_dir = backend_dir / f"agent_{agent_name}" + + if not agent_dir.exists(): + print(f" ❌ {agent_name}: Directory not found") + return False + + test_path = agent_dir / test_file + if not test_path.exists(): + print(f" ⚠️ {agent_name}: No {test_file} found, skipping") + return True # Not a failure, just skip + + # Set environment for mocked lambdas + env = os.environ.copy() + env['MOCK_LAMBDAS'] = 'true' + + # Run the test with uv + success, stdout, stderr = run_command( + ['uv', 'run', test_file], + cwd=str(agent_dir) + ) + + if success: + print(f" ✅ {agent_name}: Test passed") + if stdout and "Status Code: 200" in stdout: + # Extract key info from successful runs + for line in stdout.split('\n'): + if 'Tagged:' in line or 'Success:' in line or 'Message:' in line: + print(f" {line.strip()}") + else: + print(f" ❌ {agent_name}: Test failed") + if stderr: + # Show first error line + error_lines = [l for l in stderr.split('\n') if l.strip()] + if error_lines: + print(f" Error: {error_lines[0][:100]}") + + return success + +def main(): + """Run all agent tests.""" + print("="*60) + print("TESTING ALL AGENTS") + print("Running individual test_simple.py in each agent directory") + print("="*60) + + # List of agents to test + agents = [ + 'tagger', + 'reporter', + 'charter', + 'retirement', + 'planner' + ] + + results = {} + + for agent in agents: + print(f"\n{agent.upper()} Agent:") + results[agent] = test_agent(agent) + + # Summary + print("\n" + "="*60) + print("TEST SUMMARY") + print("="*60) + + passed = sum(1 for r in results.values() if r) + failed = sum(1 for r in results.values() if not r) + + print(f"Passed: {passed}/{len(agents)}") + print(f"Failed: {failed}/{len(agents)}") + + if failed > 0: + print("\nFailed agents:") + for agent, success in results.items(): + if not success: + print(f" - {agent}") + + print("="*60) + + if failed > 0: + print("\n⚠️ SOME TESTS FAILED") + sys.exit(1) + else: + print("\n✅ ALL TESTS PASSED!") + sys.exit(0) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 00000000..8452693f --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,519 @@ +import boto3 +import json +import os +import time +from boto3.session import Session +from bedrock_agentcore_starter_toolkit import Runtime + +def sleep_time_10(): + return 10 + + +def setup_cognito_user_pool(): + boto_session = Session() + region = boto_session.region_name + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name=region) + + try: + # Create User Pool + user_pool_response = cognito_client.create_user_pool( + PoolName='MCPServerPool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 8 + } + } + ) + pool_id = user_pool_response['UserPool']['Id'] + + # Create App Client + app_client_response = cognito_client.create_user_pool_client( + UserPoolId=pool_id, + ClientName='MCPServerPoolClient', + GenerateSecret=False, + ExplicitAuthFlows=[ + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_REFRESH_TOKEN_AUTH' + ] + ) + client_id = app_client_response['UserPoolClient']['ClientId'] + + # Create User + cognito_client.admin_create_user( + UserPoolId=pool_id, + Username='testuser', + TemporaryPassword='Temp123!', + MessageAction='SUPPRESS' + ) + + # Set Permanent Password + cognito_client.admin_set_user_password( + UserPoolId=pool_id, + Username='testuser', + Password='MyPassword123!', + Permanent=True + ) + + # Authenticate User and get Access Token + auth_response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': 'testuser', + 'PASSWORD': 'MyPassword123!' + } + ) + bearer_token = auth_response['AuthenticationResult']['AccessToken'] + + # Output the required values + print(f"Pool id: {pool_id}") + print(f"Discovery URL: https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration") + print(f"Client ID: {client_id}") + print(f"Bearer Token: {bearer_token}") + + # Return values if needed for further processing + return { + 'pool_id': pool_id, + 'client_id': client_id, + 'bearer_token': bearer_token, + 'discovery_url':f"https://cognito-idp.{region}.amazonaws.com/{pool_id}/.well-known/openid-configuration" + } + + except Exception as e: + print(f"Error: {e}") + return None + + +def create_agentcore_role(agent_name, region="us-east-1"): + iam_client = boto3.client('iam', region) + agentcore_role_name = f'agentcore-{agent_name}-role' + boto_session = Session(region_name=region) + account_id = boto3.client("sts", region).get_caller_identity()["Account"] + # Read optional environment variables for bucket/regions; fall back to wildcards when not provided + vector_bucket = os.getenv("VECTOR_BUCKET", "*") + bedrock_region = os.getenv("BEDROCK_REGION", region) + sagemaker_endpoint = os.getenv("SAGEMAKER_ENDPOINT", "*") + + role_policy = { + "Version": "2012-10-17", + "Statement": [ + # CloudWatch Logs + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": f"arn:aws:logs:{region}:{account_id}:*" + }, + # SQS access for orchestrator + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Resource": f"arn:aws:sqs:{region}:{account_id}:*" + }, + # Lambda invocation for orchestrator to call other agents + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": f"arn:aws:lambda:{region}:{account_id}:function:alex-*" + }, + # Aurora Data API access + { + "Effect": "Allow", + "Action": [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ], + # Using wildcard to allow access to the data API resources; tighten if you have the ARN + "Resource": "*" + }, + # Secrets Manager for database credentials + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": "*" + }, + # S3 Vectors access for all agents + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + f"arn:aws:s3:::{vector_bucket}", + f"arn:aws:s3:::{vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + "Effect": "Allow", + "Action": [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ], + "Resource": f"arn:aws:s3vectors:{region}:{account_id}:bucket/{vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + "Effect": "Allow", + "Action": [ + "sagemaker:InvokeEndpoint" + ], + "Resource": f"arn:aws:sagemaker:{region}:{account_id}:endpoint/{sagemaker_endpoint}" + }, + # Bedrock access for all agents (supports multiple regions for different models) + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Resource": "*" + + }, + # Bedrock AgentCore access for SQS orchestrator + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:InvokeAgentRuntime" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:runtime/*" + ] + }, + # ECR image access (for pulling images if needed) + { + "Sid": "ECRImageAccess", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:GetAuthorizationToken" + ], + "Resource": [ + f"arn:aws:ecr:{region}:{account_id}:repository/*" + ] + }, + # ECR token access + { + "Sid": "ECRTokenAccess", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + }, + # X-Ray and CloudWatch metrics + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Resource": ["*"] + }, + { + "Effect": "Allow", + "Resource": "*", + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + } + }, + # Bedrock AgentCore workload identity access tokens + { + "Sid": "GetAgentAccessToken", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Resource": [ + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:{region}:{account_id}:workload-identity-directory/default/workload-identity/{agent_name}-*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + "Sid": "SSMParameterStoreAccess", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + } + ] + } + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeRolePolicy", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": f"{account_id}" + }, + "ArnLike": { + "aws:SourceArn": f"arn:aws:bedrock-agentcore:{region}:{account_id}:*" + } + } + } + ] + } + + assume_role_policy_document_json = json.dumps( + assume_role_policy_document + ) + role_policy_document = json.dumps(role_policy) + # Create IAM Role for the Lambda function + try: + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Pause to make sure role is created + time.sleep(sleep_time_10()) + except iam_client.exceptions.EntityAlreadyExistsException: + print("Role already exists -- deleting and creating it again") + policies = iam_client.list_role_policies( + RoleName=agentcore_role_name, + MaxItems=100 + ) + print("policies:", policies) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agentcore_role_name, + PolicyName=policy_name + ) + print(f"deleting {agentcore_role_name}") + iam_client.delete_role( + RoleName=agentcore_role_name + ) + print(f"recreating {agentcore_role_name}") + agentcore_iam_role = iam_client.create_role( + RoleName=agentcore_role_name, + AssumeRolePolicyDocument=assume_role_policy_document_json + ) + + # Attach the AWSLambdaBasicExecutionRole policy + print(f"attaching role policy {agentcore_role_name}") + try: + iam_client.put_role_policy( + PolicyDocument=role_policy_document, + PolicyName="AgentCorePolicy", + RoleName=agentcore_role_name + ) + except Exception as e: + print(e) + + return agentcore_iam_role + + +def check_status(agentcore_client, agent_arn): + """Check the status of an agent using the AgentCore client""" + try: + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + end_status = ['READY', 'CREATE_FAILED', 'DELETE_FAILED', 'UPDATE_FAILED'] + while status not in end_status: + time.sleep(10) + status_response = agentcore_client.get_agent_runtime(agentRuntimeArn=agent_arn) + status = status_response.get('status', 'UNKNOWN') + print(status) + return status + except Exception as e: + print(f"Error checking agent status: {e}") + return "ERROR" + +def configureruntime(agent_name, agentcore_iam_role_arn, python_file_name): + boto_session = Session(region_name=os.getenv("DEFAULT_AWS_REGION", "us-east-1")) + region = boto_session.region_name + + agentcore_runtime = Runtime() + + response = agentcore_runtime.configure( + entrypoint=python_file_name, + execution_role=agentcore_iam_role_arn, #['Role']['Arn'], + auto_create_ecr=True, + requirements_file="requirements.txt", + region=region, + agent_name=agent_name + ) + return response, agentcore_runtime + + + +def save_env_to_ssm(env_file_path=None, prefix="/alex/env/", region=None): + """ + Save all environment variables from .env file to AWS Systems Manager Parameter Store. + + Args: + env_file_path: Path to .env file (defaults to .env in current directory) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Summary of saved parameters + """ + import os + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + saved_params = {} + skipped_params = {} + + # Read .env file manually to get all key-value pairs + with open("../../.env", 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse key=value pairs + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Skip empty values + if not value: + skipped_params[key] = "Empty value" + continue + + # Create SSM parameter name + param_name = f"{prefix}{key}" + + try: + # Save to SSM Parameter Store as SecureString for sensitive data + ssm.put_parameter( + Name=param_name, + Value=value, + Type='SecureString', + Overwrite=True, + Description=f"Environment variable {key} from .env file" + ) + saved_params[key] = param_name + print(f"✅ Saved {key} to SSM parameter: {param_name}") + + except Exception as e: + skipped_params[key] = f"Error saving to SSM: {str(e)}" + print(f"❌ Failed to save {key}: {e}") + + summary = { + "saved_count": len(saved_params), + "skipped_count": len(skipped_params), + "saved_parameters": saved_params, + "skipped_parameters": skipped_params, + "prefix": prefix, + "region": region + } + + print(f"\n📊 Summary: {len(saved_params)} parameters saved, {len(skipped_params)} skipped") + return summary + + +def load_env_from_ssm(prefix="/alex/env/", region=None, set_env_vars=True): + """ + Load environment variables from AWS Systems Manager Parameter Store. + + Args: + prefix: SSM parameter prefix to search for (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + set_env_vars: Whether to set the loaded values as environment variables + + Returns: + dict: Dictionary of loaded environment variables + """ + import os + + # Set default values + if region is None: + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + + # Get SSM client + ssm = boto3.client('ssm', region_name=region) + + loaded_env = {} + + try: + # Get all parameters with the specified prefix + paginator = ssm.get_paginator('get_parameters_by_path') + + for page in paginator.paginate( + Path=prefix, + Recursive=True, + WithDecryption=True # Decrypt SecureString parameters + ): + for param in page['Parameters']: + # Extract the environment variable name from the parameter name + env_var_name = param['Name'][len(prefix):] + env_var_value = param['Value'] + + loaded_env[env_var_name] = env_var_value + + # Set as environment variable if requested + if set_env_vars: + os.environ[env_var_name] = env_var_value + + print(f"✅ Loaded {env_var_name} from SSM parameter: {param['Name']}") + + print(f"\n📊 Loaded {len(loaded_env)} environment variables from SSM") + return loaded_env + + except Exception as e: + print(f"❌ Error loading environment variables from SSM: {e}") + return {} + + +def load_env_for_agent(agent_name, prefix="/alex/env/", region=None): + """ + Convenience function for agents to load environment variables from SSM. + Automatically sets them as environment variables. + + Args: + agent_name: Name of the agent (for logging purposes) + prefix: SSM parameter prefix (defaults to /alex/env/) + region: AWS region (defaults to DEFAULT_AWS_REGION env var or us-east-1) + + Returns: + dict: Dictionary of loaded environment variables + """ + print(f"🔧 Loading environment variables for agent: {agent_name}") + return load_env_from_ssm(prefix=prefix, region=region, set_env_vars=True) \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 7d8f9384..46d460bc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -202,27 +202,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "autopep8" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycodestyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/30873d2b7b57dee9263e53d142da044c4600a46f2d28374b3e38b023df16/autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", size = 92210, upload-time = "2025-01-14T14:46:18.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, +] + +[[package]] +name = "aws-requests-auth" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/b2/455c0bfcbd772dafd4c9e93c4b713e36790abf9ccbca9b8e661968b29798/aws-requests-auth-0.4.3.tar.gz", hash = "sha256:33593372018b960a31dbbe236f89421678b885c35f0b6a7abfae35bb77e069b2", size = 10096, upload-time = "2020-05-27T23:10:34.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/11/5dc8be418e1d54bed15eaf3a7461797e5ebb9e6a34869ad750561f35fa5b/aws_requests_auth-0.4.3-py2.py3-none-any.whl", hash = "sha256:646bc37d62140ea1c709d20148f5d43197e6bd2d63909eb36fa4bb2345759977", size = 6838, upload-time = "2020-05-27T23:10:33.658Z" }, +] + [[package]] name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alex-database" }, + { name = "bedrock-agentcore" }, + { name = "bedrock-agentcore-starter-toolkit" }, { name = "boto3" }, { name = "langfuse" }, + { name = "nest-asyncio" }, { name = "openai-agents" }, + { name = "playwright" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, + { name = "strands-agents" }, + { name = "strands-agents-tools" }, ] [package.metadata] requires-dist = [ { name = "alex-database", editable = "database" }, + { name = "bedrock-agentcore", specifier = ">=1.0.3" }, + { name = "bedrock-agentcore-starter-toolkit", specifier = ">=0.1.26" }, { name = "boto3", specifier = ">=1.40.29" }, { name = "langfuse", specifier = ">=3.3.4" }, + { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "openai-agents", specifier = ">=0.3.0" }, + { name = "playwright", specifier = ">=1.55.0" }, { name = "pydantic-ai", specifier = ">=1.0.6" }, { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "strands-agents", specifier = ">=1.13.0" }, + { name = "strands-agents-tools", specifier = ">=0.2.12" }, ] [[package]] @@ -234,32 +270,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "bedrock-agentcore" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/57/eee3388b8e6e38c5d667f54053df9718ad1be456ce5885865c8074d726b4/bedrock_agentcore-1.0.3.tar.gz", hash = "sha256:67dcc3a47815d36f368fc3f51636b9ee6a0e0ca8a908868d5bafd4a88efcad93", size = 267907, upload-time = "2025-10-16T18:26:30.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/cb/d6970e331a65ccb9eb6848cd49542161cd6c99ad00d6e5fc3e164d6dc8ca/bedrock_agentcore-1.0.3-py3-none-any.whl", hash = "sha256:6d281bedcec04405c50a108a977ec10d647b10983f05439aa7c7b258fd512c9a", size = 79695, upload-time = "2025-10-16T18:26:28.625Z" }, +] + +[[package]] +name = "bedrock-agentcore-starter-toolkit" +version = "0.1.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autopep8" }, + { name = "bedrock-agentcore" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "openapi-spec-validator" }, + { name = "prance" }, + { name = "prompt-toolkit" }, + { name = "py-openapi-schema-to-json-schema" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "requests" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "starlette" }, + { name = "toml" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/67/4802cc51a125ec6ac84a1432b9f066794ee8b6729f2fb90efe9353a343d8/bedrock_agentcore_starter_toolkit-0.1.26.tar.gz", hash = "sha256:2ca47524029d73910e18115799b3066ebfd0ad9864490f415010cc97ff22fa35", size = 543528, upload-time = "2025-10-17T16:58:35.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/2d/2988955906035f0a6a6eda2d24ac9c229c65122311895772addc1e6434d8/bedrock_agentcore_starter_toolkit-0.1.26-py3-none-any.whl", hash = "sha256:5a6568f1c68779ec901c2ab52dd7e7d20f039dc62c009c3dc85947b1660bea25", size = 200048, upload-time = "2025-10-17T16:58:34.028Z" }, +] + [[package]] name = "boto3" -version = "1.40.29" +version = "1.40.55" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/da/403df8247b332668a5bf2fa78ee1cbe6edfafa9c70260743596e4d29989c/boto3-1.40.29.tar.gz", hash = "sha256:3abdf649163ab86929cee9a6401e3ed1aaf8aef35c95e262a1b1c496d20f4168", size = 111559, upload-time = "2025-09-11T19:24:27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/a279c054e0c9731172f05b3d118f3ffc9d74806657f84fc0c93c42d1bb5d/boto3-1.40.55.tar.gz", hash = "sha256:27e35b4fa9edd414ce06c1a748bf57cacd8203271847d93fc1053e4a4ec6e1a9", size = 111590, upload-time = "2025-10-17T19:34:56.753Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/49/704058732e48261d8194b1b108dce57421b64c5058dea58ee26ac4151f61/boto3-1.40.29-py3-none-any.whl", hash = "sha256:bb2871e9be0fe20e6605d3369d521f2079cef77df672c1ee13362746184969d6", size = 139324, upload-time = "2025-09-11T19:24:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/42/8c/559c6145d857ed953536a83f3a94915bbd5d3d2d406db1abf8bf40be7645/boto3-1.40.55-py3-none-any.whl", hash = "sha256:2e30f5a0d49e107b8a5c0c487891afd300bfa410e1d918bf187ae45ac3839332", size = 139322, upload-time = "2025-10-17T19:34:55.028Z" }, ] [[package]] name = "botocore" -version = "1.40.29" +version = "1.40.55" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/0d/3e420528a69ea8077c91d80aaeea9c2c4dcdc4115e5e267f52636f3fa4f5/botocore-1.40.29.tar.gz", hash = "sha256:4e5207acef693167bb99c08a4c24d3e9405cb9669999e272a473a04cf2ba9df9", size = 14346806, upload-time = "2025-09-11T19:24:17.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/92/dce4842b2e215d213d34b064fcdd13c6a782c43344e77336bcde586e9229/botocore-1.40.55.tar.gz", hash = "sha256:79b6472e2de92b3519d44fc1eec8c5feced7f99a0d10fdea6dc93133426057c1", size = 14446917, upload-time = "2025-10-17T19:34:47.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/9e/d9d9726a9c95179544fb7ca3cba7e1cf82b60d9bc030eb034aa4abc22454/botocore-1.40.29-py3-none-any.whl", hash = "sha256:69a180a027044ae01db80b4cce4b2f93b6e4731fd7a8393c54f708c5677af85f", size = 14021245, upload-time = "2025-09-11T19:24:14.947Z" }, + { url = "https://files.pythonhosted.org/packages/21/30/f13bbc36e83b78777ff1abf50a084efcc3336b808e76560d8c5a0c9219e0/botocore-1.40.55-py3-none-any.whl", hash = "sha256:cdc38f7a4ddb30a2cd1cdd4fabde2a5a16e41b5a642292e1c30de5c4e46f5d44", size = 14116107, upload-time = "2025-10-17T19:34:44.398Z" }, ] [[package]] @@ -337,6 +438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.3" @@ -455,6 +565,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, ] +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -464,6 +583,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -683,6 +811,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, ] +[[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/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { 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 = "griffe" version = "1.14.0" @@ -827,6 +988,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.10.0" @@ -899,6 +1072,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -931,6 +1119,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/46/edd370d47ca72ed4ca36b3c3b8f3a4ce71a310629d0bcb6ed760b04b08b1/langfuse-3.3.4-py3-none-any.whl", hash = "sha256:15b9d20878cf39a48ca9cfa7e52acdfeb043603d3a9cef8cf451687a4d838c6b", size = 318389, upload-time = "2025-09-02T15:02:37.171Z" }, ] +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, +] + [[package]] name = "logfire" version = "4.7.0" @@ -987,6 +1207,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markdownify" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/1b/6f2697b51eaca81f08852fd2734745af15718fea10222a1d40f8a239c4ea/markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c", size = 18771, upload-time = "2025-08-09T17:44:15.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, +] + +[[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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { 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" }, +] + [[package]] name = "mcp" version = "1.14.0" @@ -1036,6 +1332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/40/646448b5ad66efec097471bd5ab25f5b08360e3f34aecbe5c4fcc6845c01/mistralai-1.9.10-py3-none-any.whl", hash = "sha256:cf0a2906e254bb4825209a26e1957e6e0bacbbe61875bd22128dc3d5d51a7b0a", size = 440538, upload-time = "2025-09-02T07:44:37.5Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "multidict" version = "6.6.4" @@ -1099,6 +1404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "nexus-rpc" version = "1.1.0" @@ -1148,6 +1462,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/3b/58ee42582716645aa1a5c30c1e337dc9a7433f7f0b5ed84ef02a35368abb/openai_agents-0.3.0-py3-none-any.whl", hash = "sha256:16de8a28729ae9e27faad7ce146a4b74acf05c9eeca3fe23299f6e621a3893ed", size = 185007, upload-time = "2025-09-11T19:20:08.304Z" }, ] +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.37.0" @@ -1222,6 +1565,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/e7/6dc8ee4881889993fa4a7d3da225e5eded239c975b9831eff392abd5a5e4/opentelemetry_instrumentation_httpx-0.58b0-py3-none-any.whl", hash = "sha256:d3f5a36c7fed08c245f1b06d1efd91f624caf2bff679766df80981486daaccdb", size = 15197, upload-time = "2025-09-11T11:41:32.66Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/a9/3888cb0470e6eb48ea17b6802275ae71df411edd6382b9a8e8f391936fda/opentelemetry_instrumentation_threading-0.58b0.tar.gz", hash = "sha256:f68c61f77841f9ff6270176f4d496c10addbceacd782af434d705f83e4504862", size = 8770, upload-time = "2025-09-11T11:42:56.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/add1076cb37980e617723a96e29c84006983e8ad6fc589dde7f69ddc57d4/opentelemetry_instrumentation_threading-0.58b0-py3-none-any.whl", hash = "sha256:eacc072881006aceb5b9b6831bcdce718c67ef6f31ac0b32bd6a23a94d979b4a", size = 9312, upload-time = "2025-09-11T11:41:58.603Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.37.0" @@ -1279,6 +1636,115 @@ wheels = [ { 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 = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, +] + +[[package]] +name = "prance" +version = "25.4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "packaging" }, + { name = "requests" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/5c/afa384b91354f0dbc194dfbea89bbd3e07dbe47d933a0a2c4fb989fc63af/prance-25.4.8.0.tar.gz", hash = "sha256:2f72d2983d0474b6f53fd604eb21690c1ebdb00d79a6331b7ec95fb4f25a1f65", size = 2808091, upload-time = "2025-04-07T22:22:36.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/a8/fc509e514c708f43102542cdcbc2f42dc49f7a159f90f56d072371629731/prance-25.4.8.0-py3-none-any.whl", hash = "sha256:d3c362036d625b12aeee495621cb1555fd50b2af3632af3d825176bfb50e073b", size = 36386, upload-time = "2025-04-07T22:22:35.183Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1362,6 +1828,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, ] +[[package]] +name = "py-openapi-schema-to-json-schema" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/c5/5d6a9b08df175a886b4085eb51e0351854a96e4896a367b2373ad19d881b/py-openapi-schema-to-json-schema-0.0.3.tar.gz", hash = "sha256:d557afb6bcc45d62a1383ada0ad57515421552efa3b2e07b2264e5b9e1e9634e", size = 5964, upload-time = "2020-07-25T05:34:52.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/1a/a43f73b8762512ab3358aac96c6c6d1d9ec4dbb3bbb99d82c2e90e5f3d16/py_openapi_schema_to_json_schema-0.0.3-py3-none-any.whl", hash = "sha256:456802186309257a9667fd50eca7c6ff6eaf9930ab09dcc87c54537e01066f09", size = 6954, upload-time = "2020-07-25T05:34:50.932Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1383,6 +1858,15 @@ 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 = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1580,6 +2064,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1690,6 +2186,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1719,6 +2227,18 @@ 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 = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + [[package]] name = "rich" version = "14.1.0" @@ -1825,6 +2345,50 @@ 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 = "ruamel-yaml" +version = "0.18.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e9/39ec4d4b3f91188fad1842748f67d4e749c77c37e353c4e545052ee8e893/ruamel.yaml.clib-0.2.14.tar.gz", hash = "sha256:803f5044b13602d58ea378576dd75aa759f52116a0232608e8fdada4da33752e", size = 225394, upload-time = "2025-09-22T19:51:23.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/42/ccfb34a25289afbbc42017e4d3d4288e61d35b2e00cfc6b92974a6a1f94b/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6aeadc170090ff1889f0d2c3057557f9cd71f975f17535c26a5d37af98f19c27", size = 271775, upload-time = "2025-09-23T14:24:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/e628a92e80197ff6a79ab81ec3fa00d4cc082d58ab78d3337b7ba7043301/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5e56ac47260c0eed992789fa0b8efe43404a9adb608608631a948cee4fc2b052", size = 138842, upload-time = "2025-09-22T19:50:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c5/346c7094344a60419764b4b1334d9e0285031c961176ff88ffb652405b0c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:a911aa73588d9a8b08d662b9484bc0567949529824a55d3885b77e8dd62a127a", size = 647404, upload-time = "2025-09-22T19:50:52.921Z" }, + { url = "https://files.pythonhosted.org/packages/df/99/65080c863eb06d4498de3d6c86f3e90595e02e159fd8529f1565f56cfe2c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a05ba88adf3d7189a974b2de7a9d56731548d35dc0a822ec3dc669caa7019b29", size = 753141, upload-time = "2025-09-22T19:50:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e3/0de85f3e3333f8e29e4b10244374a202a87665d1131798946ee22cf05c7c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb04c5650de6668b853623eceadcdb1a9f2fee381f5d7b6bc842ee7c239eeec4", size = 703477, upload-time = "2025-09-22T19:50:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/0d2f09d8833c7fd77ab8efeff213093c16856479a9d293180a0d89f6bed9/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df3ec9959241d07bc261f4983d25a1205ff37703faf42b474f15d54d88b4f8c9", size = 741157, upload-time = "2025-09-23T18:42:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/959f10c2e2153cbdab834c46e6954b6dd9e3b109c8f8c0a3cf1618310985/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259", size = 745859, upload-time = "2025-09-22T19:50:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6b/e580a7c18b485e1a5f30a32cda96b20364b0ba649d9d2baaf72f8bd21f83/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c099cafc1834d3c5dac305865d04235f7c21c167c8dd31ebc3d6bbc357e2f023", size = 770200, upload-time = "2025-09-22T19:50:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/ef/44/3455eebc761dc8e8fdced90f2b0a3fa61e32ba38b50de4130e2d57db0f21/ruamel.yaml.clib-0.2.14-cp312-cp312-win32.whl", hash = "sha256:b5b0f7e294700b615a3bcf6d28b26e6da94e8eba63b079f4ec92e9ba6c0d6b54", size = 98829, upload-time = "2025-09-22T19:50:58.895Z" }, + { url = "https://files.pythonhosted.org/packages/76/ab/5121f7f3b651db93de546f8c982c241397aad0a4765d793aca1dac5eadee/ruamel.yaml.clib-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:a37f40a859b503304dd740686359fcf541d6fb3ff7fc10f539af7f7150917c68", size = 115570, upload-time = "2025-09-22T19:50:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ae/e3811f05415594025e96000349d3400978adaed88d8f98d494352d9761ee/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7e4f9da7e7549946e02a6122dcad00b7c1168513acb1f8a726b1aaf504a99d32", size = 269205, upload-time = "2025-09-23T14:24:15.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/7d51f4688d6d72bb72fa74254e1593c4f5ebd0036be5b41fe39315b275e9/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:dd7546c851e59c06197a7c651335755e74aa383a835878ca86d2c650c07a2f85", size = 137417, upload-time = "2025-09-22T19:50:59.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/08/b4499234a420ef42960eeb05585df5cc7eb25ccb8c980490b079e6367050/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:1c1acc3a0209ea9042cc3cfc0790edd2eddd431a2ec3f8283d081e4d5018571e", size = 642558, upload-time = "2025-09-22T19:51:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ba/1975a27dedf1c4c33306ee67c948121be8710b19387aada29e2f139c43ee/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2070bf0ad1540d5c77a664de07ebcc45eebd1ddcab71a7a06f26936920692beb", size = 744087, upload-time = "2025-09-22T19:51:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/20/15/8a19a13d27f3bd09fa18813add8380a29115a47b553845f08802959acbce/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd8fe07f49c170e09d76773fb86ad9135e0beee44f36e1576a201b0676d3d1d", size = 699709, upload-time = "2025-09-22T19:51:02.075Z" }, + { url = "https://files.pythonhosted.org/packages/19/ee/8d6146a079ad21e534b5083c9ee4a4c8bec42f79cf87594b60978286b39a/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59", size = 708926, upload-time = "2025-09-23T18:42:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/426b714abdc222392e68f3b8ad323930d05a214a27c7e7a0f06c69126401/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1f118b707eece8cf84ecbc3e3ec94d9db879d85ed608f95870d39b2d2efa5dca", size = 740202, upload-time = "2025-09-22T19:51:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ac/3c5c2b27a183f4fda8a57c82211721c016bcb689a4a175865f7646db9f94/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b30110b29484adc597df6bd92a37b90e63a8c152ca8136aad100a02f8ba6d1b6", size = 765196, upload-time = "2025-09-22T19:51:05.916Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/06f56a71fd55021c993ed6e848c9b2e5e9cfce180a42179f0ddd28253f7c/ruamel.yaml.clib-0.2.14-cp313-cp313-win32.whl", hash = "sha256:f4e97a1cf0b7a30af9e1d9dad10a5671157b9acee790d9e26996391f49b965a2", size = 98635, upload-time = "2025-09-22T19:51:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/51/79/76aba16a1689b50528224b182f71097ece338e7a4ab55e84c2e73443b78a/ruamel.yaml.clib-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:090782b5fb9d98df96509eecdbcaffd037d47389a89492320280d52f91330d78", size = 115238, upload-time = "2025-09-22T19:51:07.081Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/a59ff65c26aaf21a24eb38df777cb9af5d87ba8fc8107c163c2da9d1e85e/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7df6f6e9d0e33c7b1d435defb185095386c469109de723d514142632a7b9d07f", size = 271441, upload-time = "2025-09-23T14:24:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, +] + [[package]] name = "s3transfer" version = "0.14.0" @@ -1842,6 +2406,15 @@ name = "scheduler" version = "0.1.0" source = { virtual = "scheduler" } +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1851,6 +2424,27 @@ 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 = "slack-bolt" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/14/0f490731fbfc95b5711e8124b30bb6e2a4be5edad22256891adad66f8b79/slack_bolt-1.26.0.tar.gz", hash = "sha256:b0b806b9dcf009ee50172830c1d170e231cd873c5b819703bbcdc59a0fe5ff3e", size = 129915, upload-time = "2025-10-06T23:41:51.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/77/57aff95f88f2f1a959088ff29c45ceaf8dcad540e9966b647d6942a007f0/slack_bolt-1.26.0-py2.py3-none-any.whl", hash = "sha256:d8386ecb27aaa487c1a5e4b43a4125f532100fc3a26e49dd2a66f5837ff2e3be", size = 230084, upload-time = "2025-10-06T23:41:50.118Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.37.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/c2/0a174a155623d7dc3ed4d1360cdf755590acdc2c3fc9ce0d2340f468909f/slack_sdk-3.37.0.tar.gz", hash = "sha256:242d6cffbd9e843af807487ff04853189b812081aeaa22f90a8f159f20220ed9", size = 241612, upload-time = "2025-10-06T23:07:20.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/fd/a502ee24d8c7d12a8f749878ae0949b8eeb50aeac22dc5a613d417a256d0/slack_sdk-3.37.0-py2.py3-none-any.whl", hash = "sha256:e108a0836eafda74d8a95e76c12c2bcb010e645d504d8497451e4c7ebb229c87", size = 302751, upload-time = "2025-10-06T23:07:19.542Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1860,6 +2454,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + [[package]] name = "sse-starlette" version = "3.0.2" @@ -1885,6 +2488,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] +[[package]] +name = "strands-agents" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/78/39bd0254fd9586fec1345f1fb93f13e242af1254d3665b5613f74d4e8eef/strands_agents-1.13.0.tar.gz", hash = "sha256:50a15d9174be62eb2a55b33e966e675632ddb89dab192ba0cf68f3d25beb2f65", size = 430554, upload-time = "2025-10-17T19:01:18.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/29/5617003dd640a005b3b3a00b9a736333b2939942e3bc3a3d9cc976de854a/strands_agents-1.13.0-py3-none-any.whl", hash = "sha256:ac77bce99e55416c54f8d6dbc0301d5a6c6e417dc99dbe6bb445f7c715d89116", size = 223508, upload-time = "2025-10-17T19:01:16.65Z" }, +] + +[[package]] +name = "strands-agents-tools" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aws-requests-auth" }, + { name = "botocore" }, + { name = "dill" }, + { name = "markdownify" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "rich" }, + { name = "slack-bolt" }, + { name = "strands-agents" }, + { name = "sympy" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/6b/af065e011dbb9e09eff8db78fa7d254f9ebae388c3baf15c846c653bc1b2/strands_agents_tools-0.2.12.tar.gz", hash = "sha256:fc653100034390f5a59d3850ef361d7a432efef4be4fa3195ecfcbdc5240e2c4", size = 448959, upload-time = "2025-10-17T19:02:35.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/65/a0688b324a71f3179b04e608a04e9c108bc01e6295be5e74f10e5bb65045/strands_agents_tools-0.2.12-py3-none-any.whl", hash = "sha256:ba9ba1b3c723afdf741d3fa9fa9d21e17cd893258273c50095e8f4d069c2cf84", size = 299449, upload-time = "2025-10-17T19:02:33.503Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "temporalio" version = "1.17.0" @@ -1938,6 +2602,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/9b/0e0bf82214ee20231845b127aa4a8015936ad5a46779f30865d10e404167/tokenizers-0.22.0-cp39-abi3-win_amd64.whl", hash = "sha256:c78174859eeaee96021f248a56c801e36bfb6bd5b067f2e95aa82445ca324f00", size = 2680494, upload-time = "2025-08-29T10:25:35.14Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1950,6 +2623,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + [[package]] name = "types-protobuf" version = "6.30.2.20250914" @@ -1992,6 +2680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -2014,6 +2711,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.2.13" diff --git a/backend/watch_agents_agentcore.py b/backend/watch_agents_agentcore.py new file mode 100644 index 00000000..393cd980 --- /dev/null +++ b/backend/watch_agents_agentcore.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Watch CloudWatch logs from all Alex agents in real-time. +Polls all 5 agent log groups simultaneously and displays output with color coding. +""" + +import boto3 +import time +import sys +from datetime import datetime, timedelta +from typing import Dict, List, Optional +import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +import re + +# ANSI color codes for terminal output +COLORS = { + 'PLANNER': '\033[94m', # Blue + 'TAGGER': '\033[93m', # Yellow + 'REPORTER': '\033[92m', # Green + 'CHARTER': '\033[96m', # Cyan + 'RETIREMENT': '\033[95m', # Magenta + 'PLANNER_AGENTCORE': '\033[104m', # Blue background + 'TAGGER_AGENTCORE': '\033[103m', # Yellow background + 'REPORTER_AGENTCORE': '\033[102m', # Green background + 'CHARTER_AGENTCORE': '\033[106m', # Cyan background + 'RETIREMENT_AGENTCORE': '\033[105m', # Magenta background + 'ERROR': '\033[91m', # Red + 'LANGFUSE': '\033[35m', # Purple (for LangFuse-related logs) + 'RESET': '\033[0m', # Reset to default + 'BOLD': '\033[1m', # Bold text +} + +# Agent log groups - will be populated dynamically from SSM +LOG_GROUPS = {} + +def get_agent_log_groups(region: str = 'us-east-1') -> Dict[str, str]: + """Get all agent log groups from SSM parameters.""" + ssm_client = boto3.client('ssm', region_name=region) + log_groups = {} + + # Agent names to check + agent_names = ['planner', 'tagger', 'reporter', 'charter', 'retirement'] + + for agent_name in agent_names: + try: + # Get agent ARN from SSM + parameter_name = f"/agents/{agent_name}_agent_arn" + response = ssm_client.get_parameter(Name=parameter_name) + agent_arn = response['Parameter']['Value'] + + # Extract agent ID from ARN + # ARN format: arn:aws:bedrock:us-east-1:123456789012:agent/AGENT_ID + agent_id = agent_arn.split('/')[-1] + + # Construct log group name + log_group_name = f"/aws/bedrock-agentcore/runtimes/{agent_id}-DEFAULT" + + # Add both Lambda and AgentCore log groups + # log_groups[agent_name.upper()] = f"/aws/lambda/alex-{agent_name}" + log_groups[f"{agent_name.upper()}_AGENTCORE"] = log_group_name + + print(f"Found {agent_name} agent: {agent_id}") + # print(f" Lambda logs: /aws/lambda/alex-{agent_name}") + print(f" AgentCore logs: {log_group_name}") + + except Exception as e: + print(f"Warning: Could not get {agent_name} agent ARN from SSM: {e}") + # Fallback to Lambda logs only + log_groups[agent_name.upper()] = f"/aws/lambda/alex-{agent_name}" + + return log_groups + + +class AgentLogWatcher: + """Watches CloudWatch logs for all agents.""" + + def __init__(self, region: str = 'us-east-1', lookback_minutes: int = 5): + """Initialize the log watcher.""" + self.logs_client = boto3.client('logs', region_name=region) + self.lookback_minutes = lookback_minutes + + # Get log groups dynamically from SSM + print("Getting agent log groups from SSM parameters...") + global LOG_GROUPS + LOG_GROUPS = get_agent_log_groups(region) + + if not LOG_GROUPS: + print("No log groups found! Check your SSM parameters.") + sys.exit(1) + + self.last_timestamps = {agent: 0 for agent in LOG_GROUPS} + + print(f"\nWatching {len(LOG_GROUPS)} log groups:") + for agent, log_group in LOG_GROUPS.items(): + color = COLORS.get(agent, '') + reset = COLORS['RESET'] + print(f" {color}{agent:20}{reset} -> {log_group}") + print() + + def get_log_events(self, agent: str, start_time: int) -> List[Dict]: + """Get log events for a specific agent.""" + log_group = LOG_GROUPS[agent] + + try: + # Get all log streams in the log group + response = self.logs_client.describe_log_streams( + logGroupName=log_group, + orderBy='LastEventTime', + descending=True, + limit=5 # Get the 5 most recent streams + ) + + if not response.get('logStreams'): + return [] + + # Collect events from all recent streams + all_events = [] + for stream in response['logStreams']: + stream_name = stream['logStreamName'] + + # Get events from this stream + try: + events_response = self.logs_client.filter_log_events( + logGroupName=log_group, + logStreamNames=[stream_name], + startTime=start_time, + limit=100 + ) + + events = events_response.get('events', []) + all_events.extend(events) + + except Exception as e: + # Stream might have been deleted or have no events + continue + + # Sort events by timestamp + all_events.sort(key=lambda x: x['timestamp']) + + # Update last timestamp for this agent + if all_events: + self.last_timestamps[agent] = all_events[-1]['timestamp'] + 1 + + return all_events + + except self.logs_client.exceptions.ResourceNotFoundException: + print(f"{COLORS['ERROR']}Log group {log_group} not found{COLORS['RESET']}") + return [] + except Exception as e: + print(f"{COLORS['ERROR']}Error fetching logs for {agent}: {e}{COLORS['RESET']}") + return [] + + def format_message(self, agent: str, event: Dict) -> str: + """Format a log message with color coding.""" + timestamp = datetime.fromtimestamp(event['timestamp'] / 1000).strftime('%H:%M:%S.%f')[:-3] + message = event['message'].rstrip() + + # Color the agent name + agent_color = COLORS.get(agent, COLORS['RESET']) + + # Make AgentCore logs more distinctive + if '_AGENTCORE' in agent: + agent_label = f"{agent_color}[{agent:20}]{COLORS['RESET']}" + else: + agent_label = f"{agent_color}[{agent:15}]{COLORS['RESET']}" + + # Highlight specific message types + if 'ERROR' in message or 'Exception' in message or 'Failed' in message: + message_color = COLORS['ERROR'] + elif 'LangFuse' in message or 'Observability' in message: + message_color = COLORS['LANGFUSE'] + elif 'DEBUG:' in message: + message_color = COLORS.get(agent, '') + else: + message_color = '' + + if message_color: + message = f"{message_color}{message}{COLORS['RESET']}" + + return f"{timestamp} {agent_label} {message}" + + def poll_agent(self, agent: str, start_time: int) -> List[str]: + """Poll a single agent for new log events.""" + events = self.get_log_events(agent, start_time) + formatted_messages = [] + + for event in events: + formatted_messages.append(self.format_message(agent, event)) + + return formatted_messages + + def watch(self, poll_interval: int = 2): + """Watch all agent logs continuously.""" + print(f"{COLORS['BOLD']}Watching CloudWatch logs for all Alex agents...{COLORS['RESET']}") + print(f"Looking back {self.lookback_minutes} minutes initially") + print(f"Polling every {poll_interval} seconds") + print(f"Press Ctrl+C to stop\n") + + # Initial start time (lookback period) + initial_start = int((datetime.now() - timedelta(minutes=self.lookback_minutes)).timestamp() * 1000) + + # Set initial timestamps + for agent in LOG_GROUPS: + self.last_timestamps[agent] = initial_start + + try: + while True: + # Poll all agents in parallel + with ThreadPoolExecutor(max_workers=5) as executor: + futures = { + executor.submit(self.poll_agent, agent, self.last_timestamps[agent]): agent + for agent in LOG_GROUPS + } + + # Collect and display results + all_messages = [] + for future in as_completed(futures): + messages = future.result() + all_messages.extend(messages) + + # Sort messages by timestamp and display + all_messages.sort() + for message in all_messages: + print(message) + + # Wait before next poll + time.sleep(poll_interval) + + except KeyboardInterrupt: + print(f"\n{COLORS['BOLD']}Stopped watching logs{COLORS['RESET']}") + sys.exit(0) + except Exception as e: + print(f"{COLORS['ERROR']}Error: {e}{COLORS['RESET']}") + sys.exit(1) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Watch CloudWatch logs from all Alex agents') + parser.add_argument( + '--region', + default='us-east-1', + help='AWS region (default: us-east-1)' + ) + parser.add_argument( + '--lookback', + type=int, + default=5, + help='Minutes to look back initially (default: 5)' + ) + parser.add_argument( + '--interval', + type=int, + default=2, + help='Polling interval in seconds (default: 2)' + ) + + args = parser.parse_args() + + watcher = AgentLogWatcher(region=args.region, lookback_minutes=args.lookback) + watcher.watch(poll_interval=args.interval) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/frontend/pages/advisor-team.tsx b/frontend/pages/advisor-team.tsx index 7dc01600..82098342 100644 --- a/frontend/pages/advisor-team.tsx +++ b/frontend/pages/advisor-team.tsx @@ -23,7 +23,7 @@ interface Job { } interface AnalysisProgress { - stage: 'idle' | 'starting' | 'planner' | 'parallel' | 'completing' | 'complete' | 'error'; + stage: 'idle' | 'starting' | 'planner' | 'parallel' | 'completing' | 'complete' | 'error' | 'partial'; message: string; activeAgents: string[]; error?: string; @@ -116,6 +116,26 @@ export default function AdvisorTeam() { setTimeout(() => { router.push(`/analysis?job_id=${jobId}`); }, 1500); + } else if (job.status === 'max_tokens_exceeded') { + setProgress({ + stage: 'partial', + message: 'Analysis partially completed - portfolio too complex', + activeAgents: [], + error: 'Analysis stopped due to maximum token limit. Your portfolio may be too large or complex for automated analysis. Contact support for assistance.' + }); + + if (pollInterval) { + clearInterval(pollInterval); + setPollInterval(null); + } + + // Treat as a partial success - still navigate to results page + emitAnalysisCompleted(jobId); + fetchJobs(); + + setTimeout(() => { + router.push(`/analysis?job_id=${jobId}`); + }, 3000); } else if (job.status === 'failed') { setProgress({ stage: 'error', @@ -250,6 +270,8 @@ export default function AdvisorTeam() { return 'text-green-600'; case 'failed': return 'text-red-500'; + case 'max_tokens_exceeded': + return 'text-yellow-600'; case 'running': return 'text-blue-600'; default: @@ -396,7 +418,7 @@ export default function AdvisorTeam() { {job.status.charAt(0).toUpperCase() + job.status.slice(1)} - {job.status === 'completed' && ( + {(job.status === 'completed' || job.status === 'max_tokens_exceeded') && ( +

+ For assistance with complex portfolios, please contact our support team. +

+ + + + + + ); + } + // Tab content renderers const renderOverview = () => { diff --git a/frontend/pages/dashboard.tsx b/frontend/pages/dashboard.tsx index ba8c361f..685201cb 100644 --- a/frontend/pages/dashboard.tsx +++ b/frontend/pages/dashboard.tsx @@ -126,7 +126,14 @@ export default function Dashboard() { }); if (!userResponse.ok) { - throw new Error(`Failed to sync user: ${userResponse.status}`); + // Get the error details + const errorText = await userResponse.text(); + console.error("API Error Details:", { + status: userResponse.status, + statusText: userResponse.statusText, + body: errorText + }); + throw new Error(`Failed to sync user: ${userResponse.status} - ${errorText}`); } const response = await userResponse.json(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 957e71fe..b9b70037 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,12 +15,20 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] } diff --git a/guides/4_researcher_AgentCore.md b/guides/4_researcher_AgentCore.md new file mode 100644 index 00000000..ddd2eb36 --- /dev/null +++ b/guides/4_researcher_AgentCore.md @@ -0,0 +1,675 @@ +# Guide 4: AgentCore Researcher - Testing, Deployment & Operations + +This guide covers the AgentCore-based researcher agent located in `backend/agent_researcher`. This is an alternative implementation that uses browser automation to research financial topics and integrates directly with the database system. + +## 📋 Prerequisites + +Before starting, ensure you have: +1. **Infrastructure Setup**: Complete guides 1-3 (permissions, SageMaker, ingestion) +2. **Database**: Complete guide 5 (Aurora/PostgreSQL database with test data) +3. **AWS CLI**: Configured with your credentials +4. **Browser Dependencies**: AgentCore Browser tool for web automation +5. **Environment Variables**: All required SSM parameters configured + +## What You'll Deploy + +The AgentCore Researcher is a Python-based agent that: +- Uses AWS Bedrock with Claude/Nova models for AI capabilities +- Employs AgentCore Browser tool for web automation and scraping +- Integrates directly with Aurora/PostgreSQL database +- Automatically saves research reports to the job system +- Provides comprehensive error handling and timeout protection +- Supports both specific topic research and auto-discovery + +Here's how it fits into the Alex architecture: + +```mermaid +graph LR + User[User] -->|Research Request| AR[AgentCore
Researcher] + Schedule[EventBridge
Every 2hrs] -->|Trigger| SchedLambda[Lambda
Scheduler] + SchedLambda -->|Auto Research| AR + AR -->|Browser Automation| Web[Financial
Websites] + AR -->|AI Analysis| Bedrock[AWS Bedrock
Nova/Claude] + AR -->|Save Report| DB[(Aurora/PostgreSQL
Database)] + AR -->|Store Research| API[API Gateway] + API -->|Process| Lambda[Lambda
Ingest] + Lambda -->|Embeddings| SM[SageMaker
all-MiniLM-L6-v2] + Lambda -->|Store| S3V[(S3 Vectors)] + User -->|Search| S3V + + %% User & Interaction + style User fill:#4A90E2,stroke:#2E5F99,stroke-width:2px,color:#ffffff + + %% Core AI Agent + style AR fill:#E74C3C,stroke:#C0392B,stroke-width:3px,color:#ffffff + + %% AI/ML Services + style Bedrock fill:#F39C12,stroke:#D68910,stroke-width:2px,color:#ffffff + style SM fill:#F39C12,stroke:#D68910,stroke-width:2px,color:#ffffff + + %% Storage & Data + style S3V fill:#27AE60,stroke:#1E8449,stroke-width:2px,color:#ffffff + style DB fill:#27AE60,stroke:#1E8449,stroke-width:2px,color:#ffffff + + %% API & Processing + style API fill:#8E44AD,stroke:#7D3C98,stroke-width:2px,color:#ffffff + style Lambda fill:#8E44AD,stroke:#7D3C98,stroke-width:2px,color:#ffffff + + %% External Services + style Web fill:#95A5A6,stroke:#7F8C8D,stroke-width:2px,color:#ffffff + + %% Scheduling & Automation + style Schedule fill:#3498DB,stroke:#2980B9,stroke-width:2px,color:#ffffff + style SchedLambda fill:#3498DB,stroke:#2980B9,stroke-width:2px,color:#ffffff +``` + +## 🧪 Local Testing + +### Setup Environment + +Navigate to the agent folder: + +```bash +cd backend/agent_researcher +``` + +Install browser dependencies: + +```bash +# Install Python dependencies (includes AgentCore Browser tool) +uv sync + +# No additional browser installation needed - AgentCore Browser is included +``` + +Verify environment variables are loaded: +```bash +# The agent automatically loads from SSM parameters +# You should see this output when running tests: +# ✅ Loaded environment variables from SSM +``` + +### Simple Test (Database Integration) + +Run the comprehensive database integration test: + +```bash +uv run test_simple.py +``` + +This test performs a complete workflow: +- ✅ **Creates test job**: Inserts a new job record in the database +- ✅ **Runs researcher agent**: Executes Tesla stock analysis with browser automation +- ✅ **Gathers data**: Uses AgentCore Browser tool to visit financial websites +- ✅ **AI analysis**: Processes data with Bedrock models +- ✅ **Saves report**: Stores research in `report_payload` field +- ✅ **Verifies record**: Confirms database save was successful +- ✅ **Cleanup**: Deletes test job to maintain clean state +- ✅ **Proper termination**: Exits with appropriate status codes + +Expected successful output: +``` +🚀 Starting Researcher Agent Tests +============================================================ +🔍 Testing Researcher Agent with Database Integration... +🎯 Using AURORA backend +✅ Created test job: cbb019d1-d1ed-43eb-bdea-2f1783e3ff55 +📊 Research Topic: Tesla Stock Analysis +🌐 Using model: us.amazon.nova-pro-v1:0 +🌍 Using region: us-west-2 + +🔍 Running Researcher Agent... +📊 Research completed, result length: 763 characters +✅ Saved research report to database +✅ Verified report saved in database + Report content length: 763 + Topic: Tesla Stock Analysis + Agent: researcher + Generated at: 2025-10-27T01:47:23.152101 + Content snippet: Based on typical data points for Tesla's stock... +✅ Researcher Agent generated substantial output +✅ Response appears to be about the requested topic +✅ Deleted test job: cbb019d1-d1ed-43eb-bdea-2f1783e3ff55 + +============================================================ +✅ All tests completed successfully! +``` + +### Manual Research Execution + +Run the researcher agent directly with specific topics: + +```bash +# Research a specific company +python3 -c " +from agent import create_agent_and_run +result = create_agent_and_run('Apple Stock Analysis') +print('Research Result:') +print('=' * 50) +print(result) +print('=' * 50) +" + +# Research a market sector +python3 -c " +from agent import create_agent_and_run +result = create_agent_and_run('Technology Sector Analysis') +print(result) +" + +# Auto topic discovery (agent finds trending topics) +python3 -c " +from agent import create_agent_and_run +result = create_agent_and_run() # No topic provided +print('Auto-discovered topic research:') +print(result) +" +``` + +### Integration with Job System + +Test the full database integration workflow: + +```python +# Example: Create and process research job +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'database')) + +from src.models import Database +from src.schemas import JobCreate + +# Initialize database +db = Database() + +# Create research job +job = JobCreate( + clerk_user_id="test_user_001", + job_type="instrument_research", + request_payload={"topic": "NVIDIA Stock Analysis", "urgency": "high"} +) +job_id = db.jobs.create(job.model_dump()) +print(f"Created research job: {job_id}") + +# Manually trigger research (in production this would be automated) +from agent import create_agent_and_run +result = create_agent_and_run("NVIDIA Stock Analysis") + +# Update job with results +report_payload = { + "content": result, + "topic": "NVIDIA Stock Analysis", + "agent": "researcher", + "generated_at": "2025-10-27T10:30:00" +} +db.jobs.update_report(job_id, report_payload) + +# Verify results +saved_job = db.jobs.find_by_id(job_id) +print(f"Job status: {saved_job.get('status')}") +print(f"Report length: {len(saved_job.get('report_payload', {}).get('content', ''))}") + +# Cleanup +db.jobs.delete(job_id) +print(f"Deleted job: {job_id}") +``` + +## 🔧 Configuration + +### Environment Variables + +The agent automatically loads these SSM parameters: + +**Required for Database:** +- `/alex/env/AURORA_CLUSTER_ARN` - Aurora cluster ARN (if using Aurora) +- `/alex/env/AURORA_SECRET_ARN` - Database credentials secret (if using Aurora) +- `/alex/env/SQLALCHEMY_DATABASE_URI` - PostgreSQL connection (if using PostgreSQL) +- `/alex/env/DB_BACKEND` - Database backend: 'aurora' or 'postgres' + +**Required for AI:** +- `/alex/env/BEDROCK_MODEL_ID` - AI model ID (e.g., 'us.amazon.nova-pro-v1:0') +- `/alex/env/BEDROCK_REGION` - AWS region for Bedrock (e.g., 'us-west-2') + +**Required for Document Ingestion:** +- `/alex/env/ALEX_API_ENDPOINT` - API endpoint for document ingestion +- `/alex/env/ALEX_API_KEY` - API key for ingestion service + +**Optional:** +- `/alex/env/AURORA_DATABASE` - Database name (defaults to 'alex') +- `/alex/env/DEFAULT_AWS_REGION` - Default AWS region + +### Agent Configuration + +The researcher can be configured for different research modes: + +```python +# agent.py configuration options + +# Browser timeout settings +BROWSER_TIMEOUT = 30000 # 30 seconds per page load +NAVIGATION_TIMEOUT = 60000 # 60 seconds for complex navigation + +# Research scope +DEFAULT_RESEARCH_TOPICS = [ + "market trends", + "earnings reports", + "economic indicators", + "sector analysis" +] + +# Website targets for research +FINANCIAL_WEBSITES = [ + "https://finance.yahoo.com", + "https://www.marketwatch.com", + "https://www.bloomberg.com" +] +``` + +### Model Selection + +Recommended models for different use cases: + +**Cost-Effective:** +```bash +# Amazon Nova Pro (US regions) +BEDROCK_MODEL_ID=us.amazon.nova-pro-v1:0 +BEDROCK_REGION=us-east-1 +``` + +**High-Quality Analysis:** +```bash +# Claude Sonnet (multiple regions) +BEDROCK_MODEL_ID=anthropic.claude-3-5-sonnet-20241022-v2:0 +BEDROCK_REGION=us-west-2 +``` + +**Open Source:** +```bash +# OpenAI OSS (US West only) +BEDROCK_MODEL_ID=openai.gpt-oss-120b-1:0 +BEDROCK_REGION=us-west-2 +``` + +## 🚀 Deployment Options + +### Option 1: Lambda Deployment + +Deploy as AWS Lambda function for serverless execution: + +```bash +cd terraform/4_researcher +``` + +Update `terraform.tfvars`: +```hcl +# Agent configuration +agent_type = "agentcore" +agent_source_dir = "../../backend/agent_researcher" +runtime = "python3.12" +memory_size = 1024 +timeout = 300 + +# Environment +aws_region = "us-east-1" +bedrock_model_id = "us.amazon.nova-pro-v1:0" +bedrock_region = "us-west-2" +``` + +Deploy: +```bash +terraform init +terraform plan +terraform apply +``` + +### Option 2: Container Deployment + +Package as Docker container for ECS/Fargate: + +```dockerfile +# Dockerfile for agent_researcher +FROM public.ecr.aws/lambda/python:3.12 + +# Install system dependencies +RUN dnf install -y wget gnupg + +# Install Python dependencies (includes AgentCore Browser) +COPY requirements.txt . +RUN pip install -r requirements.txt + +# Copy agent code +COPY . . + +# Set entrypoint +CMD ["agent.lambda_handler"] +``` + +Build and deploy: +```bash +# Build container +docker build -t alex-researcher-agent . + +# Push to ECR +aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com +docker tag alex-researcher-agent:latest $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/alex-researcher-agent:latest +docker push $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/alex-researcher-agent:latest +``` + +### Option 3: Local Cron Job + +Set up scheduled local execution: + +```bash +# Add to crontab for automated research +crontab -e + +# Add this line for research every 2 hours +0 */2 * * * cd /path/to/alex/backend/agent_researcher && /usr/local/bin/uv run -c "from agent import create_agent_and_run; create_agent_and_run()" >> /tmp/alex-research.log 2>&1 +``` + +## 📊 Monitoring & Operations + +### CloudWatch Integration + +For Lambda deployment, monitor these metrics: + +```bash +# View execution logs +aws logs tail /aws/lambda/alex-researcher-agent --follow + +# Check error rates +aws cloudwatch get-metric-statistics \ + --namespace AWS/Lambda \ + --metric-name Errors \ + --dimensions Name=FunctionName,Value=alex-researcher-agent \ + --start-time 2025-10-27T00:00:00Z \ + --end-time 2025-10-27T23:59:59Z \ + --period 3600 \ + --statistics Sum +``` + +### Key Performance Indicators + +Monitor these metrics for optimal performance: + +**Execution Metrics:** +- **Duration**: Typical range 60-180 seconds +- **Memory Usage**: Peak around 512MB with browser +- **Success Rate**: Target >95% completion rate +- **Research Quality**: Content length >500 characters + +**Error Patterns:** +- **Browser Timeouts**: Websites blocking automation +- **Model Limits**: Rate limiting or quota exceeded +- **Database Errors**: Connection or permission issues +- **Network Issues**: Website unavailability + +### Health Checks + +Create monitoring scripts: + +```python +# health_check.py +import sys +sys.path.append('../database') +from src.models import Database +from agent import create_agent_and_run + +def health_check(): + """Verify agent and database connectivity""" + try: + # Test database connection + db = Database() + test_query = db.client.query("SELECT 1 as test") + assert test_query[0]['test'] == 1 + print("✅ Database connection: OK") + + # Test AI model access + result = create_agent_and_run("Health check test") + assert len(result) > 10 + print("✅ AI model access: OK") + + # Test browser automation + assert "test" in result.lower() + print("✅ Browser automation: OK") + + return True + except Exception as e: + print(f"❌ Health check failed: {e}") + return False + +if __name__ == "__main__": + success = health_check() + sys.exit(0 if success else 1) +``` + +Run health checks: +```bash +uv run health_check.py +``` + +## 🐛 Troubleshooting + +### Common Issues + +**1. Browser Automation Failures** +``` +Tool #X: browser +TimeoutError: Navigation timeout +``` +**Solution:** +- Websites may be slow or blocking automation +- Agent provides fallback analysis with typical market data +- Consider rotating User-Agent headers or using proxy services + +**2. Database Connection Issues** +``` +Failed to create test job: Foreign key violation +Key (clerk_user_id)=(test_user_001) is not present in table "users" +``` +**Solution:** +```bash +# Create test user in database +cd ../database +uv run reset_db.py --with-test-data +``` + +**3. Model Access Denied** +``` +AccessDeniedException: User is not authorized to perform: bedrock:InvokeModel +``` +**Solution:** +- Verify Bedrock model access in AWS console +- Check IAM permissions include `bedrock:InvokeModel` +- Ensure model is available in the specified region + +**4. Import Resolution Errors** +``` +Import "src.client" could not be resolved +``` +**Solution:** +- The test script automatically handles path resolution +- Ensure running from `backend/agent_researcher` directory +- Verify database module exists in `../database/src/` + +**5. Process Hanging** +``` +Agent execution never completes +``` +**Solution:** +- Agent includes 5-minute timeout protection +- Browser processes auto-cleanup on completion +- Use system timeout for additional protection: +```bash +timeout 600 uv run test_simple.py # 10-minute limit +``` + +### Performance Optimization + +**Reduce Research Time:** +```python +# Optimize browser settings in agent.py +browser_config = { + "headless": True, + "args": [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-images", + "--disable-javascript" # For faster loading + ] +} +``` + +**Improve Research Quality:** +```python +# Enhanced prompting in agent.py +research_prompt = """ +Analyze the following financial data for {topic}: +1. Current stock price and daily change +2. Key financial metrics (P/E, market cap) +3. Recent news and developments +4. Technical analysis indicators +5. Investment recommendation with reasoning + +Provide a concise but comprehensive analysis. +""" +``` + +**Scale for High Volume:** +- Use connection pooling for database access +- Implement caching for frequently researched topics +- Add rate limiting for external website requests +- Use async/await for concurrent research tasks + +### Debug Mode + +Enable detailed logging: + +```python +# Add to agent.py for debugging +import logging +logging.basicConfig(level=logging.DEBUG) + +# Enable browser debugging +browser_context = browser.new_context( + record_video_dir="./debug_videos", + record_har_path="./debug_requests.har" +) +``` + +Run with debug output: +```bash +PYTHONPATH=../database uv run test_simple.py 2>&1 | tee debug.log +``` + +## 🔄 Research Workflows + +### Automated Research Pipeline + +1. **Job Creation**: System or user creates research job +2. **Topic Processing**: Agent analyzes research request +3. **Web Research**: Browser automation gathers data +4. **AI Analysis**: Bedrock processes information +5. **Report Generation**: Structured analysis created +6. **Database Storage**: Results saved to job system +7. **Document Ingestion**: Optional vector store update +8. **User Notification**: Results available for review + +### Research Topics Supported + +**Individual Stocks:** +- "Tesla Stock Analysis" +- "Apple Earnings Review" +- "NVIDIA Growth Prospects" + +**Market Sectors:** +- "Technology Sector Overview" +- "Healthcare Investment Trends" +- "Energy Sector Performance" + +**Economic Indicators:** +- "Federal Reserve Policy Impact" +- "Inflation Effects on Markets" +- "Employment Data Analysis" + +**Market Trends:** +- "AI Stock Performance" +- "ESG Investment Trends" +- "Crypto Market Analysis" + +### Custom Research Requests + +```python +# Example: Comprehensive research request +research_request = { + "topic": "Renewable Energy Stocks", + "focus_areas": [ + "Solar energy companies", + "Wind power investments", + "Battery technology stocks", + "Government policy impact" + ], + "analysis_depth": "comprehensive", + "include_charts": True, + "time_horizon": "12 months" +} + +result = create_agent_and_run(research_request["topic"]) +``` + +## 📈 Production Considerations + +### Scaling Guidelines + +**Low Volume (< 10 requests/day):** +- Local execution with cron scheduling +- Single Lambda function deployment +- Basic error handling and logging + +**Medium Volume (10-100 requests/day):** +- Lambda with SQS queue for reliability +- CloudWatch monitoring and alerting +- Database connection pooling +- Rate limiting for external APIs + +**High Volume (> 100 requests/day):** +- ECS/Fargate with auto-scaling +- Redis caching for common research +- Load balancing across multiple regions +- Advanced error recovery and retry logic + +### Security Best Practices + +**Network Security:** +```python +# Use VPC endpoints for AWS services +# Restrict outbound internet access +# Rotate API keys regularly +``` + +**Data Protection:** +```python +# Encrypt sensitive research data +# Implement access logging +# Use least-privilege IAM policies +``` + +**Browser Security:** +```python +# Run browser in sandbox mode +# Disable unnecessary browser features +# Use temporary profiles for each session +``` + +## 🎯 Next Steps + +After successful AgentCore researcher deployment: + +1. **Multi-Agent Integration**: Connect with other agents (reporter, charter, planner) +2. **Advanced Scheduling**: Implement intelligent research timing +3. **Quality Metrics**: Track and improve research accuracy +4. **User Feedback**: Collect and incorporate user preferences +5. **Performance Optimization**: Fine-tune for speed and cost +6. **Continue to Database Setup**: Proceed to [5_database.md](5_database.md) + +The AgentCore researcher provides a solid foundation for automated financial research with direct database integration and comprehensive error handling. diff --git a/guides/6b_agents_AgentCore.md b/guides/6b_agents_AgentCore.md new file mode 100644 index 00000000..63e8c30c --- /dev/null +++ b/guides/6b_agents_AgentCore.md @@ -0,0 +1,152 @@ +# Guide: Deploying Agent Core Agents + +Bedrock Agent Core agents are developed under their respective `agent_{agentname}` directories. +This is an alternative agent development on the newly released Bedrock Agentcore framework and can work alongside the existing App Runner-based agents. + +The primary implementation file for each agent resides in the `agent_{agentname}/agent.py` directory. Each agent's main functionality is encapsulated within a class that is registered as an entry point using the `@app.entrypoint` decorator, where `app` is an instance of `BedrockAgentCoreApp()`. + +For more details on the Bedrock AgentCore framework and its capabilities, refer to the [official AWS Bedrock AgentCore documentation](https://docs.aws.amazon.com/bedrock-agentcore/). + +These agents leverage the Bedrock Agentcore framework and are tested locally using the following commands: + + +- **Unit Tests**: Run `uv run test_simple.py` within each agent's directory to validate individual functionality. +- **Integration Tests**: Use `backend/test_full_agentcore.py` to test all agents together and ensure seamless integration. + +This guide explains how to deploy these agents using Terraform and helper scripts provided in the `6_agentcore` directory. + +## Directory Structure + +The `6_agentcore` directory contains the following key files: + +- **`deploy_agents.py`**: Script to deploy individual or all Agent Core agents. +- **`destroy_agents.py`**: Script to destroy deployed agents. +- **`cleanup_agents.py`**: Script to clean up resources related to agents. +- **`main.tf`**: Terraform configuration file for defining infrastructure. +- **`variables.tf`**: File defining input variables for Terraform. +- **`outputs.tf`**: File defining output values for Terraform. +- **`terraform.tfvars`**: File for specifying variable values. +- **`test_agent_lifecycle.py`**: Test script for validating the lifecycle of agents. + + + +## Test Agents Locally + +Let's test each agent locally, starting with the simplest. . + +**In directory**: +- `backend/agent_tagger` +- `backend/agent_reporter` +- `backend/agent_charter` +- `backend/agent_retirement` +- `backend/agent_planner` + +```bash +uv run test_simple.py +``` + +**Expected output**: You will see printed results indicating the success of the tests, along with any relevant output data. + +## Deployment Steps + +### 1. Environment Setup + +Ensure the following environment variables are set in your `.env` file: + +- `DEFAULT_AWS_REGION`: AWS region for deployment (e.g., `us-east-1`). +- `BEDROCK_MODEL_ID`: Bedrock model to use. + +### 2. Initialize Terraform + +Navigate to the `6_agentcore` directory and initialize Terraform: + +```bash +cd terraform/6_agentcore +terraform init +``` + +### 3. Deploy Infrastructure + +Review and apply the Terraform configuration: + +```bash +terraform plan +terraform apply +``` + +### 4. Deploy Agents + +Use the `deploy_agents.py` script to deploy specific agents. For example, to deploy the `planner` agent: + +```bash +python deploy_agents.py planner +``` + +To deploy all agents: + +```bash +python deploy_agents.py all +``` + +The following agents are supported: + +- `planner` +- `tagger` +- `reporter` +- `charter` +- `retirement` + +### 5. Verify Deployment + +Check the AWS Management Console to verify that the agents have been deployed successfully. The ARN of each deployed agent is saved in AWS Systems Manager Parameter Store under the path `/agents/{agent_name}_agent_arn`. + +## Helper Scripts + +- **`deploy_agents.py`**: Automates the deployment of agents by setting up IAM roles, copying necessary files, and launching the agents. +- **`destroy_agents.py`**: Destroys deployed agents and cleans up associated resources. +- **`cleanup_agents.py`**: Removes temporary files and directories created during deployment. + +## Testing + +Run the `test_agent_lifecycle.py` script to validate the lifecycle of deployed agents: + +```bash +python test_agent_lifecycle.py +``` + +## Cleanup + +To destroy the infrastructure and clean up resources, use the following commands: + +```bash +terraform destroy +python destroy_agents.py +``` + +## SQS Orchestrator Lambda + +The `sqs_orchestrator` Lambda function acts as a bridge between SQS messages and the Agent Core agents. It is designed to process incoming SQS messages and invoke the appropriate Agent Core agent, such as the `planner` agent. It is deployed within the `6_agentcore` Terraform configuration. + +### Key Features + +- **Message Handling**: Processes SQS messages containing job details. +- **Agent Invocation**: Uses the Bedrock AgentCore API to invoke agents with the provided payload. +- **Error Handling**: Handles errors such as token limits and logs detailed information for debugging. +- **Integration with SSM**: Retrieves agent ARNs from AWS Systems Manager Parameter Store. + +### Workflow + +1. **Receive SQS Message**: The Lambda function is triggered by an SQS event. +2. **Extract Job ID**: Parses the `job_id` from the message body. +3. **Invoke Agent**: Calls the AgentCore API with the job details. +4. **Log Results**: Logs success or failure for each job. + +### Local Testing + +You can test the Lambda function locally using the provided `__main__` block in `lambda_handler.py`. For example: + +```python +python lambda_handler.py +``` + +This will simulate an SQS event and invoke the Lambda function with a test payload. diff --git a/guides/architecture_agentcore.md b/guides/architecture_agentcore.md new file mode 100644 index 00000000..d94dc8f0 --- /dev/null +++ b/guides/architecture_agentcore.md @@ -0,0 +1,202 @@ +# Alex Architecture Overview (S3 Vectors Version) + +## System Architecture + +The Alex platform uses a modern serverless architecture on AWS, combining AI services with cost-effective infrastructure: + + +```mermaid +graph TB + %% API Gateway + APIGW[fa:fa-shield-alt API Gateway
REST API
API Key Auth] + + %% Backend Services + Lambda[fa:fa-bolt Lambda
alex-ingest
Document Processing] + AgentCore[fa:fa-robot Bedrock AgentCore
Multi-Agent Platform
Planner, Reporter, Charter, Retirement] + + %% Scheduler Components + EventBridge[fa:fa-clock EventBridge
Scheduler
Every 2 Hours] + SchedulerLambda[fa:fa-bolt Lambda
alex-scheduler
Trigger Research] + + %% AI Services + SageMaker[fa:fa-brain SageMaker
Embedding Model
all-MiniLM-L6-v2] + Bedrock[fa:fa-robot AWS Bedrock
Nova Pro via LiteLLM
AgentCore Runtime] + + %% Data Storage + S3Vectors[fa:fa-database S3 Vectors
Vector Storage
90% Cost Reduction!] + SQS[fa:fa-arrows-alt SQS
Agent Orchestration
Fan-out Pattern] + + %% Connections + AgentCore -->|Store Results| APIGW + AgentCore -->|Execute via| Bedrock + APIGW -->|Invoke| Lambda + + EventBridge -->|Every 2hrs| SchedulerLambda + SchedulerLambda -->|Trigger via SQS| SQS + SQS -->|Fan-out to| AgentCore + + Lambda -->|Get Embeddings| SageMaker + Lambda -->|Store Vectors| S3Vectors + + AgentCore -->|Tool Registry| Bedrock + + %% Styling + classDef aws fill:#FF9900,stroke:#232F3E,stroke-width:2px,color:#fff + classDef ai fill:#10B981,stroke:#047857,stroke-width:2px,color:#fff + classDef storage fill:#3B82F6,stroke:#1E40AF,stroke-width:2px,color:#fff + classDef highlight fill:#90EE90,stroke:#228B22,stroke-width:3px,color:#000 + classDef scheduler fill:#9333EA,stroke:#6B21A8,stroke-width:2px,color:#fff + classDef agentcore fill:#DC2626,stroke:#991B1B,stroke-width:3px,color:#fff + + class APIGW,Lambda,SageMaker,SchedulerLambda aws + class Bedrock,AgentCore ai + class S3Vectors storage + class S3Vectors highlight + class EventBridge scheduler + class SQS storage + class AgentCore agentcore +``` + + +## Component Details + +### 1. **Bedrock AgentCore** +- **Agents**: Planner, Tagger, Reporter with AgentCore Browser Tool, Charter, Retirement +- **Purpose**: Multi-agent orchestration platform +- **Runtime**: AWS Bedrock AgentCore with Nova Pro +- **Features**: Shared tool registry, parallel execution, standardized templates + +### 2. **API Gateway** +- **Type**: REST API +- **Auth**: API Key authentication +- **Endpoints**: `/ingest` (POST) +- **Purpose**: Secure access to Lambda functions + +### 3. **Lambda Functions** +- **alex-ingest**: Processes documents and stores embeddings + - Runtime: Python 3.12 + - Memory: 512MB + - Timeout: 30 seconds +- **alex-scheduler**: Triggers automated research + - Runtime: Python 3.11 + - Memory: 128MB + - Timeout: 150 seconds + +### 4. **S3 Vectors** +- **Purpose**: Native vector storage in S3 +- **Features**: + - Sub-second similarity search + - Automatic optimization + - No minimum charges + - Strongly consistent writes +- **Cost**: ~$30/month (vs ~$300/month for OpenSearch) +- **Scale**: Millions of vectors per index + + +### 5. **SageMaker Serverless** +- **Model**: sentence-transformers/all-MiniLM-L6-v2 +- **Purpose**: Generate 384-dimensional embeddings +- **Memory**: 3GB +- **Concurrency**: 10 max + +### 6. **EventBridge Scheduler** +- **Rule**: alex-research-schedule +- **Schedule**: Every 2 hours +- **Target**: alex-scheduler Lambda +- **Purpose**: Automated research generation + +### 7. **AWS Bedrock** +- **Provider**: AWS Bedrock +- **Model**: OpenAI OSS 120B (open-weight model) +- **Region**: us-west-2 (model only available here) +- **Purpose**: Research generation and analysis +- **Features**: 128K context window, cross-region access + +## Data Flow + +1. **Manual Research Flow**: + ``` + User → App Runner → Bedrock (generate) → API Gateway → Lambda → S3 Vectors + ``` + +2. **Automated Research Flow**: + ``` + EventBridge (every 2hrs) → Lambda Scheduler → App Runner → Bedrock → API Gateway → Lambda → S3 Vectors + ``` + +3. **Direct Ingest Flow**: + ``` + User → API Gateway → Lambda → SageMaker (embed) → S3 Vectors + ``` + +4. **Search Flow** (future): + ``` + User → API Gateway → Lambda → S3 Vectors (similarity search) + ``` + +## Cost Optimization + +| Component | Monthly Cost | Notes | +|-----------|-------------|-------| +| S3 Vectors | ~$30 | 90% cheaper than OpenSearch! | +| SageMaker Serverless | ~$5-10 | Pay per request | +| Lambda | ~$1 | Minimal invocations | +| App Runner | ~$5 | 1 vCPU, 2GB RAM | +| API Gateway | ~$1 | REST API | +| **Total** | **~$42-47** | Previously ~$250+ | + +## Security Features + +- **API Gateway**: API key authentication +- **IAM Roles**: Least privilege access +- **S3 Vectors**: Always private (no public access) +- **App Runner**: HTTPS by default +- **Secrets**: Environment variables for API keys + +## Deployment Architecture + +```mermaid +graph LR + Dev[fa:fa-laptop Developer] + GH[fa:fa-code-branch GitHub Repo] + TF[fa:fa-cog Terraform] + AWS[fa:fa-cloud AWS] + + Dev -->|Push| GH + Dev -->|Run| TF + TF -->|Deploy| AWS + + subgraph AWS Infrastructure + S3[S3 State] + Resources[All Resources] + end + + TF -.->|State| S3 + TF -->|Create| Resources +``` + +## Technology Stack + +- **Infrastructure**: Terraform +- **Compute**: Lambda, App Runner +- **AI/ML**: SageMaker, AWS Bedrock +- **Storage**: S3 Vectors +- **API**: API Gateway +- **Languages**: Python 3.12 +- **Container**: Docker + +## Key Advantages of S3 Vectors + +1. **Cost**: 90% reduction vs traditional vector databases +2. **Simplicity**: Just S3 - no complex infrastructure +3. **Scale**: Handles millions of vectors +4. **Performance**: Sub-second queries +5. **Integration**: Native AWS service + +## Future Enhancements + +- Frontend application (Next.js) +- User authentication +- Advanced search features +- Real-time updates +- Analytics dashboard \ No newline at end of file diff --git a/terraform/6_agentcore/.terraform.lock.hcl b/terraform/6_agentcore/.terraform.lock.hcl new file mode 100644 index 00000000..c7a8c412 --- /dev/null +++ b/terraform/6_agentcore/.terraform.lock.hcl @@ -0,0 +1,63 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + hashes = [ + "h1:A7EnRBVm4h9ryO9LwxYnKr4fy7ExPMwD5a1DsY7m1Y0=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.4" + hashes = [ + "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", + "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", + "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", + "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", + "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", + "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", + "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", + "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", + "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", + "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", + "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", + ] +} diff --git a/terraform/6_agentcore/cleanup_agents.py b/terraform/6_agentcore/cleanup_agents.py new file mode 100644 index 00000000..7aa1bad1 --- /dev/null +++ b/terraform/6_agentcore/cleanup_agents.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Clean up existing agent resources and parameters +""" + +import boto3 +import sys + +def cleanup_agent_resources(): + """Clean up existing agent SSM parameters and prepare for fresh deployment""" + + region = "us-east-1" + ssm = boto3.client('ssm', region_name=region) + + # List of agent parameters to clean up + agent_names = ['planner', 'tagger', 'reporter', 'charter', 'retirement'] + + print("🧹 Cleaning up existing agent resources...") + + for agent_name in agent_names: + param_name = f"/agents/{agent_name}_agent_arn" + try: + # Try to get the parameter first + response = ssm.get_parameter(Name=param_name) + print(f" Found parameter: {param_name}") + + # Delete the parameter + ssm.delete_parameter(Name=param_name) + print(f" ✅ Deleted parameter: {param_name}") + + except ssm.exceptions.ParameterNotFound: + print(f" ℹ️ Parameter not found: {param_name}") + except Exception as e: + print(f" ❌ Error with {param_name}: {e}") + + print("\n✅ Cleanup completed. You can now run terraform apply again.") + print("\nNote: This cleanup only removes SSM parameters.") + print("If there are still AgentCore conflicts, you may need to manually") + print("clean up resources in the AWS Bedrock console.") + +if __name__ == "__main__": + cleanup_agent_resources() \ No newline at end of file diff --git a/terraform/6_agentcore/deploy_agents.py b/terraform/6_agentcore/deploy_agents.py new file mode 100644 index 00000000..d084e897 --- /dev/null +++ b/terraform/6_agentcore/deploy_agents.py @@ -0,0 +1,132 @@ +import sys +import os +import boto3 +import json +import time + +# Add backend directory to Python path +backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'backend')) +if backend_path not in sys.path: + sys.path.insert(0, backend_path) + +from utils import configureruntime, create_agentcore_role, save_env_to_ssm + + +def deploy_agent(agent_name): + """Deploy a single agent using the bedrock agentcore toolkit""" + + # Validate agent name + valid_agents = ['planner', 'tagger', 'reporter', 'charter', 'retirement'] + if agent_name not in valid_agents: + raise ValueError(f"Invalid agent name: {agent_name}. Must be one of: {valid_agents}") + + + # Get AWS region from environment + region = os.getenv("DEFAULT_AWS_REGION", "us-east-1") + print(f"Deploying agent '{agent_name}' in region '{region}'") + + # Set working directory to the specific agent directory + agent_dir = os.path.join(backend_path, f"agent_{agent_name}") + if not os.path.exists(agent_dir): + raise FileNotFoundError(f"Agent directory not found: {agent_dir}") + + # Check for agent.py file + agent_file = os.path.join(agent_dir, "agent.py") + if not os.path.exists(agent_file): + raise FileNotFoundError(f"agent.py not found in: {agent_dir}") + + # Copy required files to agent directory + print(f"Copying required files to agent directory...") + + # Copy database src directory + database_src_dir = os.path.join(backend_path, "database", "src") + agent_src_dir = os.path.join(agent_dir, "src") + + if os.path.exists(database_src_dir): + import shutil + if os.path.exists(agent_src_dir): + shutil.rmtree(agent_src_dir) # Remove existing src directory + shutil.copytree(database_src_dir, agent_src_dir) + print(f" ✓ Copied database/src to {agent_name}/src") + else: + print(f" ⚠ Database src directory not found: {database_src_dir}") + + # Copy utils.py file + utils_source = os.path.join(backend_path, "utils.py") + utils_dest = os.path.join(agent_dir, "utils.py") + + if os.path.exists(utils_source): + import shutil + shutil.copy2(utils_source, utils_dest) + print(f" ✓ Copied utils.py to {agent_name} directory") + else: + print(f" ⚠ utils.py not found: {utils_source}") + + # Change to agent directory for deployment + original_cwd = os.getcwd() + try: + os.chdir(agent_dir) + print(f"Changed to directory: {agent_dir}") + + # Create IAM role for the agent + print(f"Creating IAM role for agent: {agent_name}") + agent_iam_role = create_agentcore_role(agent_name=agent_name, region=region) + agent_role_arn = agent_iam_role['Role']['Arn'] + agent_role_name = agent_iam_role['Role']['RoleName'] + print(f"Created IAM role: {agent_role_name}") + print(f"Role ARN: {agent_role_arn}") + + # Configure runtime (this looks for pyproject.toml or requirements.txt automatically) + print(f"Configuring runtime for agent: {agent_name}") + + + _, agent_runtime = configureruntime(agent_name, agent_role_arn, "agent.py") + + # Launch the agent + print(f"Launching agent: {agent_name}") + launch_result = agent_runtime.launch(auto_update_on_conflict=True) + agent_id = launch_result.agent_id + agent_arn = launch_result.agent_arn + + print(f"Agent deployed successfully!") + print(f"Agent ID: {agent_id}") + print(f"Agent ARN: {agent_arn}") + + # Save ARN to parameter store for future reference + ssm = boto3.client('ssm', region_name=region) + ssm.put_parameter( + Name=f'/agents/{agent_name}_agent_arn', + Value=agent_arn, + Type='String', + Overwrite=True + ) + print(f"Saved agent ARN to SSM parameter: /agents/{agent_name}_agent_arn") + + return agent_arn + + finally: + # Always return to original directory + os.chdir(original_cwd) + +if __name__ == "__main__": + + if len(sys.argv) != 2: + print("Usage: python deploy_agents.py ") + print("Valid agent names: planner, tagger, reporter, charter, retirement,all") + sys.exit(1) + + agent_name = sys.argv[1] + if agent_name == "all": + agents_to_deploy = ['planner', 'tagger', 'reporter', 'charter', 'retirement'] + else: + agents_to_deploy = [agent_name] + + try: + for agent_name in agents_to_deploy: + agent_arn = deploy_agent(agent_name) + print(f"\n✅ Successfully deployed agent: {agent_name}") + print(f"Agent ARN: {agent_arn}") + except Exception as e: + print(f"\n❌ Failed to deploy agent: {agent_name}") + print(f"Error: {str(e)}") + sys.exit(1) \ No newline at end of file diff --git a/terraform/6_agentcore/destroy_agents.py b/terraform/6_agentcore/destroy_agents.py new file mode 100644 index 00000000..3b45677c --- /dev/null +++ b/terraform/6_agentcore/destroy_agents.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Destroy deployed Bedrock AgentCore agents and their resources. + +Note: AWS Bedrock AgentCore agents are managed services that don't require explicit deletion. +The agent runtime will be automatically cleaned up when the associated infrastructure resources are removed. +This script focuses on cleaning up: +- IAM roles and policies +- SSM parameters +- Local configuration files +""" +import json +import boto3 +from botocore.exceptions import ClientError +import os +import sys +from bedrock_agentcore_starter_toolkit import Runtime + +def destroy_agent(agent_name=None): + """ + Tear down a deployed Bedrock AgentCore agent and its resources. + + Args: + agent_name: Specific agent to destroy, or None to destroy all + """ + + # Define the 5 agents explicitly + agent_names = ['planner', 'tagger', 'reporter', 'charter', 'retirement'] + + if agent_name: + if agent_name not in agent_names: + print(f"❌ Unknown agent: {agent_name}") + print(f" Valid agents: {', '.join(agent_names)}") + return False + agents_to_destroy = [agent_name] + else: + agents_to_destroy = agent_names + + success_count = 0 + total_count = len(agents_to_destroy) + + # Get region from environment or default + region = os.environ.get('AWS_REGION', 'us-east-1') + ssm = boto3.client('ssm', region_name=region) + + for agent_name_current in agents_to_destroy: + try: + # Read agent ARN from SSM parameter + parameter_name = f'/agents/{agent_name_current}_agent_arn' + + print(f"\n🧹 Destroying agent: {agent_name_current}") + print(f" Reading parameter: {parameter_name}") + + try: + response = ssm.get_parameter(Name=parameter_name) + agent_arn = response['Parameter']['Value'] + print(f" Agent ARN: {agent_arn}") + + # Extract agent ID from ARN (format: arn:aws:bedrock:region:account:agent/agent-id) + agent_id = agent_arn.split('/')[-1] + print(f" Agent ID: {agent_id}") + + except ssm.exceptions.ParameterNotFound: + print(f"❌ Parameter not found: {parameter_name}") + print(f" Agent {agent_name_current} may not be deployed or already destroyed") + continue + except Exception as e: + print(f"❌ Error reading parameter {parameter_name}: {e}") + continue + + # Delete the AgentCore runtime using the correct AWS API + agentcore_deletion_success = False + try: + print(f" Deleting AgentCore runtime...") + + # Use the correct bedrock-agentcore-control client for deletion + agentcore_control_client = boto3.client('bedrock-agentcore-control', region_name=region) + + try: + # Delete the AgentCore Runtime using the agent ID + response = agentcore_control_client.delete_agent_runtime( + agentRuntimeId=agent_id + ) + + print(f" ✅ AgentCore Runtime {agent_id} deletion initiated") + if 'status' in response: + print(f" Status: {response['status']}") + agentcore_deletion_success = True + + except Exception as delete_error: + print(f" ⚠️ Could not delete AgentCore runtime: {delete_error}") + # Check if it's because the runtime doesn't exist + if "NotFound" in str(delete_error) or "ResourceNotFound" in str(delete_error): + print(f" ℹ️ AgentCore runtime may already be deleted") + agentcore_deletion_success = True # Consider this a success since it's already gone + else: + agentcore_deletion_success = False + + except Exception as e: + print(f" ⚠️ Error accessing AgentCore control client: {e}") + print(f" ℹ️ Continuing with other cleanup steps...") + agentcore_deletion_success = False + + # Track cleanup success for different components + cleanup_success = { + 'agentcore_runtime': agentcore_deletion_success, + 'local_config': False, + 'iam_role': False + } + + # Clean up local configuration files and directories for this agent + try: + import shutil + agent_dir = f"../../backend/agent_{agent_name_current}" + + # Files to delete + config_files_to_clean = [ + f"{agent_dir}/.bedrock_agentcore.yaml", + f"{agent_dir}/agent_deployment_{agent_name_current}.json", + f"{agent_dir}/Dockerfile" + ] + + # Directories to delete + config_dirs_to_clean = [ + f"{agent_dir}/src" + ] + + files_removed = 0 + dirs_removed = 0 + + # Remove files + for config_file in config_files_to_clean: + if os.path.exists(config_file): + os.remove(config_file) + print(f" ✅ Removed file: {os.path.basename(config_file)}") + files_removed += 1 + + # Remove directories + for config_dir in config_dirs_to_clean: + if os.path.exists(config_dir): + shutil.rmtree(config_dir) + print(f" ✅ Removed directory: {os.path.basename(config_dir)}/") + dirs_removed += 1 + + if files_removed > 0 or dirs_removed > 0: + print(f" ✅ Local cleanup completed ({files_removed} files, {dirs_removed} directories)") + else: + print(f" ℹ️ No local files or directories to clean up") + + cleanup_success['local_config'] = True + + except Exception as e: + print(f" Warning: Could not clean up local config files: {e}") + cleanup_success['local_config'] = False + + # Clean up IAM role (use predictable role name) + agent_role_name = f"alex-{agent_name_current}-agent-role" + try: + print(f" Cleaning up IAM role: {agent_role_name}") + iam_client = boto3.client('iam', region_name=region) + + # Check if role exists first + try: + iam_client.get_role(RoleName=agent_role_name) + role_exists = True + except iam_client.exceptions.NoSuchEntityException: + print(f" ℹ️ IAM role {agent_role_name} already deleted or doesn't exist") + role_exists = False + except Exception as e: + print(f" Warning: Could not check if role exists: {e}") + role_exists = False + + if role_exists: + # List and delete role policies + try: + policies = iam_client.list_role_policies(RoleName=agent_role_name) + for policy_name in policies['PolicyNames']: + iam_client.delete_role_policy( + RoleName=agent_role_name, + PolicyName=policy_name + ) + print(f" ✅ Deleted role policy: {policy_name}") + except Exception as e: + print(f" Warning: Could not clean up role policies: {e}") + + # List and detach managed policies + try: + attached_policies = iam_client.list_attached_role_policies(RoleName=agent_role_name) + for policy in attached_policies['AttachedPolicies']: + iam_client.detach_role_policy( + RoleName=agent_role_name, + PolicyArn=policy['PolicyArn'] + ) + print(f" ✅ Detached managed policy: {policy['PolicyName']}") + except Exception as e: + print(f" Warning: Could not detach managed policies: {e}") + + # Delete the role + try: + iam_client.delete_role(RoleName=agent_role_name) + print(f" ✅ Deleted IAM role: {agent_role_name}") + except Exception as e: + print(f" Warning: Could not delete IAM role: {e}") + else: + print(f" ✅ IAM role cleanup not needed (role doesn't exist)") + + cleanup_success['iam_role'] = True + + except Exception as e: + print(f" Warning: IAM cleanup failed: {e}") + cleanup_success['iam_role'] = False + + # Only delete SSM parameter if all cleanup operations succeeded + if all(cleanup_success.values()): + try: + ssm.delete_parameter(Name=parameter_name) + print(f" ✅ Deleted SSM parameter: {parameter_name}") + print(f" ✅ Agent {agent_name_current} cleanup completed successfully") + success_count += 1 + except Exception as e: + print(f" ❌ Could not delete SSM parameter: {e}") + print(f" ❌ Agent {agent_name_current} cleanup completed with errors (SSM parameter retained)") + else: + failed_components = [comp for comp, success in cleanup_success.items() if not success] + print(f" ⚠️ Skipping SSM parameter deletion due to failed cleanup: {', '.join(failed_components)}") + print(f" ⚠️ Agent {agent_name_current} cleanup completed with errors (SSM parameter retained for retry)") + # Still count as processed, but with warnings + + except Exception as e: + print(f"❌ Error processing agent {agent_name_current}: {e}") + continue + + print(f"\n📊 Cleanup Summary: {success_count}/{total_count} agents processed successfully") + return True #success_count == total_count + +if __name__ == "__main__": + if len(sys.argv) > 2: + print("Usage: python destroy_agents.py [agent_name]") + print(" agent_name: Optional specific agent to destroy") + print(" If no agent specified, all deployed agents will be destroyed") + sys.exit(1) + + agent_name = sys.argv[1] if len(sys.argv) == 2 else None + + if agent_name: + print(f"🎯 Destroying specific agent: {agent_name}") + else: + print("🧹 Destroying all deployed agents...") + + success = destroy_agent(agent_name) + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/terraform/6_agentcore/main.tf b/terraform/6_agentcore/main.tf new file mode 100644 index 00000000..1dd74a61 --- /dev/null +++ b/terraform/6_agentcore/main.tf @@ -0,0 +1,457 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # Using local backend - state will be stored in terraform.tfstate in this directory + # This is automatically gitignored for security +} + +provider "aws" { + region = var.aws_region +} + +# Data source for current caller identity +data "aws_caller_identity" "current" {} + +# ======================================== +# Environment Variables to SSM +# ======================================== + +# Resource to save environment variables to SSM when .env file changes +resource "null_resource" "save_env_to_ssm" { + # Trigger when .env file changes + triggers = { + env_file_hash = filemd5("../../.env") + } + + provisioner "local-exec" { + command = "uv run save_env_to_ssm.py" + + working_dir = path.module + + environment = { + AWS_REGION = var.aws_region + } + } + + depends_on = [] + + # Add lifecycle to ensure this runs before agent deployments + lifecycle { + create_before_destroy = true + } +} + +# ======================================== +# SQS Queue for Async Job Processing +# ======================================== + +resource "aws_sqs_queue" "analysis_jobs" { + name = "alex-analysis-jobs" + delay_seconds = 0 + max_message_size = 262144 + message_retention_seconds = 86400 # 1 day + receive_wait_time_seconds = 10 # Long polling + visibility_timeout_seconds = 910 # 15 minutes + 10 seconds buffer (matches Planner Lambda timeout) + + redrive_policy = jsonencode({ + deadLetterTargetArn = aws_sqs_queue.analysis_jobs_dlq.arn + maxReceiveCount = 3 + }) + + tags = { + Project = "alex" + Part = "6" + } +} + +resource "aws_sqs_queue" "analysis_jobs_dlq" { + name = "alex-analysis-jobs-dlq" + + tags = { + Project = "alex" + Part = "6" + } +} + +# ======================================== +# IAM Role for Lambda Functions +# ======================================== + +resource "aws_iam_role" "lambda_agents_role" { + name = "alex-lambda-agents-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = { + Project = "alex" + Part = "6" + } +} + +# IAM policy for Lambda agents +resource "aws_iam_role_policy" "lambda_agents_policy" { + name = "alex-lambda-agents-policy" + role = aws_iam_role.lambda_agents_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # CloudWatch Logs + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" + }, + # SQS access for orchestrator + { + Effect = "Allow" + Action = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ] + Resource = aws_sqs_queue.analysis_jobs.arn + }, + # Lambda invocation for orchestrator to call other agents + { + Effect = "Allow" + Action = [ + "lambda:InvokeFunction" + ] + Resource = "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:alex-*" + }, + # Aurora Data API access + { + Effect = "Allow" + Action = [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ] + # Resource = var.aurora_cluster_arn + Resource = "*" + }, + # Secrets Manager for database credentials + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + Resource = "*" + }, + # S3 Vectors access for all agents + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:ListBucket" + ] + Resource = [ + "arn:aws:s3:::${var.vector_bucket}", + "arn:aws:s3:::${var.vector_bucket}/*" + ] + }, + # S3 Vectors API access for all agents + { + Effect = "Allow" + Action = [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ] + Resource = "arn:aws:s3vectors:${var.aws_region}:${data.aws_caller_identity.current.account_id}:bucket/${var.vector_bucket}/index/*" + }, + # SageMaker endpoint access for reporter agent + { + Effect = "Allow" + Action = [ + "sagemaker:InvokeEndpoint" + ] + Resource = "arn:aws:sagemaker:${var.aws_region}:${data.aws_caller_identity.current.account_id}:endpoint/${var.sagemaker_endpoint}" + }, + # Bedrock access for all agents + { + Effect = "Allow" + Action = [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ] + Resource = [ + "arn:aws:bedrock:${var.bedrock_region}::foundation-model/*", + "arn:aws:bedrock:${var.bedrock_region}:*:inference-profile/*" + ] + }, + # Bedrock AgentCore access for SQS orchestrator + { + Effect = "Allow" + Action = [ + "bedrock-agentcore:InvokeAgentRuntime" + ] + Resource = [ + "arn:aws:bedrock-agentcore:${var.aws_region}:${data.aws_caller_identity.current.account_id}:runtime/*" + ] + }, + # SSM Parameter Store access for agent ARNs and environment variables + { + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + Resource = "*" + } + ] + }) +} + +# Attach basic Lambda execution role +resource "aws_iam_role_policy_attachment" "lambda_agents_basic" { + role = aws_iam_role.lambda_agents_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# ======================================== +# SQS to AgentCore Bridge Lambda +# ======================================== + +# Package the SQS orchestrator Lambda +data "archive_file" "sqs_orchestrator" { + type = "zip" + source_file = "${path.module}/../../backend/sqs_orchestrator/lambda_handler.py" + output_path = "${path.module}/sqs_orchestrator.zip" +} + +# SQS to AgentCore Bridge Lambda Function +resource "aws_lambda_function" "sqs_orchestrator" { + function_name = "alex-sqs-orchestrator" + role = aws_iam_role.lambda_agents_role.arn + + filename = data.archive_file.sqs_orchestrator.output_path + source_code_hash = data.archive_file.sqs_orchestrator.output_base64sha256 + + handler = "lambda_handler.lambda_handler" + runtime = "python3.12" + timeout = 60 # 1 minute should be enough to invoke AgentCore + memory_size = 256 # Minimal memory needed + + tags = { + Project = "alex" + Part = "6" + Purpose = "sqs-agentcore-bridge" + } +} + +# SQS trigger for the orchestrator Lambda +resource "aws_lambda_event_source_mapping" "sqs_orchestrator" { + event_source_arn = aws_sqs_queue.analysis_jobs.arn + function_name = aws_lambda_function.sqs_orchestrator.arn + batch_size = 1 # Process one message at a time + + depends_on = [aws_lambda_function.sqs_orchestrator] +} + +# Add CloudWatch Logs for the SQS orchestrator +resource "aws_cloudwatch_log_group" "sqs_orchestrator_logs" { + name = "/aws/lambda/alex-sqs-orchestrator" + retention_in_days = 7 + + tags = { + Project = "alex" + Part = "6" + } +} + + + +resource "null_resource" "deploy_agents" { + for_each = toset(["planner", "tagger", "reporter", "charter", "retirement"]) + + triggers = { + # Redeploy if agent.py changes + agent_file_hash = filemd5("${path.module}/../../backend/agent_${each.key}/agent.py") + # Redeploy if pyproject.toml changes (dependencies) + # pyproject_hash = filemd5("${path.module}/../../backend/agent_${each.key}/pyproject.toml") + # Redeploy if deploy script changes + deploy_script_hash = filemd5("${path.module}/deploy_agents.py") + # Force redeploy to update IAM permissions + force_redeploy = "2025-10-22T21:00:00Z" + } + + provisioner "local-exec" { + command = "cd ${path.module} && uv run deploy_agents.py ${each.key}" + + environment = { + DEFAULT_AWS_REGION = var.aws_region + BEDROCK_REGION = var.bedrock_region + BEDROCK_MODEL_ID = var.bedrock_model_id + AURORA_CLUSTER_ARN = var.aurora_cluster_arn + AURORA_SECRET_ARN = var.aurora_secret_arn + DATABASE_NAME = "alex" + SQLALCHEMY_DATABASE_URI = var.sqlalchemy_database_uri + VECTOR_BUCKET = var.vector_bucket + SAGEMAKER_ENDPOINT = var.sagemaker_endpoint + POLYGON_API_KEY = var.polygon_api_key + POLYGON_PLAN = var.polygon_plan + LANGFUSE_PUBLIC_KEY = var.langfuse_public_key + LANGFUSE_SECRET_KEY = var.langfuse_secret_key + LANGFUSE_HOST = var.langfuse_host + OPENAI_API_KEY = var.openai_api_key + } + } + + # Clean up on destroy (optional) + provisioner "local-exec" { + when = destroy + command = "cd ${path.module} && uv run destroy_agents.py ${each.key}" + } + + depends_on = [ + aws_iam_role.lambda_agents_role, + null_resource.save_env_to_ssm + ] +} + +# # Data source to retrieve agent ARNs from SSM Parameter Store +# Data source to retrieve agent ARNs from SSM Parameter Store +data "aws_ssm_parameter" "agent_arns" { + for_each = toset(["planner", "tagger", "reporter", "charter", "retirement"]) + + name = "/agents/${each.key}_agent_arn" + + depends_on = [null_resource.deploy_agents] +} + +# ======================================== +# AgentCore IAM Permissions +# ======================================== + +# IAM policy for AgentCore roles to allow cross-agent communication +resource "aws_iam_role_policy" "agentcore_cross_invoke_policy" { + for_each = toset(["planner", "tagger", "reporter", "charter", "retirement"]) + + name = "agentcore-${each.key}-cross-invoke-policy" + role = "agentcore-${each.key}-role" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # Allow invoking other AgentCore runtimes + { + Effect = "Allow" + Action = [ + "bedrock-agentcore:InvokeAgentRuntime" + ] + Resource = [ + "arn:aws:bedrock-agentcore:${var.aws_region}:${data.aws_caller_identity.current.account_id}:runtime/*" + ] + }, + # Aurora Data API access + { + Effect = "Allow" + Action = [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ] + Resource = "*" + }, + # Secrets Manager for database credentials + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + Resource = "*" + }, + # S3 Vectors access + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:ListBucket" + ] + Resource = [ + "arn:aws:s3:::${var.vector_bucket}", + "arn:aws:s3:::${var.vector_bucket}/*" + ] + }, + # S3 Vectors API access + { + Effect = "Allow" + Action = [ + "s3vectors:QueryVectors", + "s3vectors:GetVectors" + ] + Resource = "arn:aws:s3vectors:${var.aws_region}:${data.aws_caller_identity.current.account_id}:bucket/${var.vector_bucket}/index/*" + }, + # SageMaker endpoint access + { + Effect = "Allow" + Action = [ + "sagemaker:InvokeEndpoint" + ] + Resource = "arn:aws:sagemaker:${var.aws_region}:${data.aws_caller_identity.current.account_id}:endpoint/${var.sagemaker_endpoint}" + }, + # Bedrock model access + { + Effect = "Allow" + Action = [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ] + Resource = [ + "arn:aws:bedrock:${var.bedrock_region}::foundation-model/*", + "arn:aws:bedrock:${var.bedrock_region}:*:inference-profile/*" + ] + }, + # SSM Parameter Store access + { + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + Resource = "*" + }, + # CloudWatch Logs (for debugging) + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" + } + ] + }) + + depends_on = [null_resource.deploy_agents] +} \ No newline at end of file diff --git a/terraform/6_agentcore/outputs.tf b/terraform/6_agentcore/outputs.tf new file mode 100644 index 00000000..470f51a2 --- /dev/null +++ b/terraform/6_agentcore/outputs.tf @@ -0,0 +1,56 @@ +output "sqs_queue_url" { + description = "URL of the SQS queue for job submission" + value = aws_sqs_queue.analysis_jobs.url +} + +output "sqs_queue_arn" { + description = "ARN of the SQS queue" + value = aws_sqs_queue.analysis_jobs.arn +} + +output "agent_arns" { + description = "ARNs of deployed AgentCore agents" + sensitive = true + value = { + for agent in ["planner", "tagger", "reporter", "charter", "retirement"] : + agent => data.aws_ssm_parameter.agent_arns[agent].value + } +} + +output "agent_iam_role_arn" { + description = "ARN of the IAM role used by agents" + value = aws_iam_role.lambda_agents_role.arn + sensitive = true +} + +output "setup_instructions" { + description = "Instructions for testing the agents" + value = <<-EOT + + ✅ Agent infrastructure deployed successfully! + + AgentCore Agents: + # - Planner (Orchestrator) + # - Tagger: + # - Reporter + # - Charter + # - Retirement + + SQS Queue: ${aws_sqs_queue.analysis_jobs.name} + + To test the system: + 1. The agents are deployed using OpenAI Agents SDK with AWS Bedrock AgentCore + 2. Run the full integration test: + cd backend + uv run test_full.py + + 3. Monitor agent performance in AWS Console: + - Bedrock AgentCore console for agent status + - CloudWatch Logs for agent execution logs + - SSM Parameter Store for agent ARNs + + Note: Agents are deployed as AgentCore endpoints, not Lambda functions. + They can be invoked directly or through the SQS orchestration system. + + EOT +} \ No newline at end of file diff --git a/terraform/6_agentcore/terraform.tfvars.example b/terraform/6_agentcore/terraform.tfvars.example new file mode 100644 index 00000000..be53d3b9 --- /dev/null +++ b/terraform/6_agentcore/terraform.tfvars.example @@ -0,0 +1,42 @@ +# Part 6: Agent Orchestra Configuration +# Copy this file to terraform.tfvars and update with your values + +# Your AWS region for Lambda functions +# Should match your database region from Part 5 +aws_region = "us-east-1" + +# Aurora cluster ARN from Part 5 (get from Terraform output) +aurora_cluster_arn = "arn:aws:rds:us-east-1:123456789012:cluster:alex-aurora-cluster" + +# Aurora secret ARN from Part 5 (get from Terraform output) +aurora_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:alex-aurora-credentials-xxxxx" + +# S3 Vectors bucket name from Part 3 +# Format: alex-vectors-{your-aws-account-id} +vector_bucket = "alex-vectors-123456789012" + +# Bedrock model configuration +# Using Amazon Nova Pro model for better reliability +bedrock_model_id = "us.amazon.nova-pro-v1:0" + +# Bedrock region (us-west-2 has the most models available) +# Note: This can be different from your Lambda region - cross-region calls work fine +bedrock_region = "us-west-2" + +# SageMaker endpoint name from Part 2 +# Default: alex-embedding-endpoint +sagemaker_endpoint = "alex-embedding-endpoint" + +# Polygon.io API configuration for real-time market prices +# Sign up for free at https://polygon.io (no credit card required) +polygon_api_key = "your_polygon_api_key_here" +polygon_plan = "free" + +# LangFuse observability configuration (optional) +# Leave commented out until we use LangFuse - we start LangFuse in Part 8 / Day 4 + +# langfuse_public_key = "pk-lf-..." # Add your pk-lf-xxx key here - be sure this is the one that starts pk +# langfuse_secret_key = "sk-lf-..." # Add your sk-lf-xxx key here - be sure this is the one that starts sk +# langfuse_host = "https://us.cloud.langfuse.com" +# OpenAI API key (required for OpenAI Agents SDK tracing to work - no balance or spend needed) +# openai_api_key = "" \ No newline at end of file diff --git a/terraform/6_agentcore/test_agent_lifecycle.py b/terraform/6_agentcore/test_agent_lifecycle.py new file mode 100644 index 00000000..6d49605e --- /dev/null +++ b/terraform/6_agentcore/test_agent_lifecycle.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Test script to validate agent deployment and cleanup functionality +""" + +import os +import sys +import subprocess +import json +import glob + +def test_deploy_and_destroy(): + """Test the deployment and destruction workflow""" + + print("🧪 Testing Agent Deployment and Cleanup Workflow") + print("=" * 60) + + # Check if we're in the right directory + if not os.path.exists("deploy_agents.py"): + print("❌ Must run from terraform/6_agents directory") + return False + + # Test with tagger agent (smallest/fastest to deploy) + test_agent = "tagger" + deployment_file = f"agent_deployment_{test_agent}.json" + + try: + # Clean up any existing deployment file + if os.path.exists(deployment_file): + os.remove(deployment_file) + print(f"🧹 Cleaned up existing {deployment_file}") + + print(f"\n📦 Testing deployment of {test_agent} agent...") + + # Test deployment (this will take a few minutes) + deploy_result = subprocess.run([ + "uv", "run", "deploy_agents.py", test_agent + ], capture_output=True, text=True, timeout=300) # 5 minute timeout + + if deploy_result.returncode != 0: + print(f"❌ Deployment failed:") + print(f"STDOUT: {deploy_result.stdout}") + print(f"STDERR: {deploy_result.stderr}") + return False + + print(f"✅ Deployment completed successfully") + + # Check if deployment file was created + if not os.path.exists(deployment_file): + print(f"❌ Deployment file not created: {deployment_file}") + return False + + # Validate deployment file content + with open(deployment_file, 'r') as f: + deployment_data = json.load(f) + + required_fields = ["agent_name", "agent_id", "agent_arn", "agent_role_arn", "region"] + missing_fields = [field for field in required_fields if not deployment_data.get(field)] + + if missing_fields: + print(f"❌ Deployment file missing required fields: {missing_fields}") + return False + + print(f"✅ Deployment file created with all required fields") + print(f" Agent ID: {deployment_data['agent_id']}") + print(f" Agent ARN: {deployment_data['agent_arn']}") + + print(f"\n🧹 Testing cleanup of {test_agent} agent...") + + # Test cleanup + destroy_result = subprocess.run([ + "uv", "run", "destroy_agents.py", test_agent + ], capture_output=True, text=True, timeout=120) # 2 minute timeout + + if destroy_result.returncode != 0: + print(f"❌ Cleanup failed:") + print(f"STDOUT: {destroy_result.stdout}") + print(f"STDERR: {destroy_result.stderr}") + return False + + print(f"✅ Cleanup completed successfully") + + # Check if deployment file was removed + if os.path.exists(deployment_file): + print(f"❌ Deployment file not removed: {deployment_file}") + return False + + print(f"✅ Deployment file cleaned up") + + return True + + except subprocess.TimeoutExpired: + print(f"❌ Test timed out") + return False + except Exception as e: + print(f"❌ Test failed with error: {e}") + return False + finally: + # Emergency cleanup + if os.path.exists(deployment_file): + print(f"🚨 Emergency cleanup: removing {deployment_file}") + try: + subprocess.run(["uv", "run", "destroy_agents.py", test_agent], + timeout=60, capture_output=True) + if os.path.exists(deployment_file): + os.remove(deployment_file) + except: + pass + +def list_deployments(): + """List all current agent deployments""" + + deployment_files = glob.glob("agent_deployment_*.json") + + if not deployment_files: + print("📋 No agent deployments found") + return + + print(f"📋 Found {len(deployment_files)} agent deployment(s):") + + for deployment_file in deployment_files: + try: + with open(deployment_file, 'r') as f: + data = json.load(f) + + agent_name = data.get("agent_name", "unknown") + agent_id = data.get("agent_id", "unknown") + region = data.get("region", "unknown") + timestamp = data.get("deployment_timestamp") + + print(f" • {agent_name}") + print(f" Agent ID: {agent_id}") + print(f" Region: {region}") + if timestamp: + import datetime + dt = datetime.datetime.fromtimestamp(timestamp) + print(f" Deployed: {dt.strftime('%Y-%m-%d %H:%M:%S')}") + print() + + except Exception as e: + print(f" • {deployment_file} (error reading: {e})") + +if __name__ == "__main__": + if len(sys.argv) > 1: + if sys.argv[1] == "list": + list_deployments() + elif sys.argv[1] == "test": + success = test_deploy_and_destroy() + sys.exit(0 if success else 1) + else: + print("Usage:") + print(" python test_agent_lifecycle.py test - Test deploy and destroy workflow") + print(" python test_agent_lifecycle.py list - List current deployments") + sys.exit(1) + else: + print("🔍 Listing current deployments...") + list_deployments() + print("\nTo test the deployment workflow, run:") + print(" python test_agent_lifecycle.py test") \ No newline at end of file diff --git a/terraform/6_agentcore/test_deploy.py b/terraform/6_agentcore/test_deploy.py new file mode 100644 index 00000000..8ffda41c --- /dev/null +++ b/terraform/6_agentcore/test_deploy.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Test script to validate the deploy_agents.py functionality +""" + +import os +import sys +import subprocess + +def test_deploy_script(): + """Test the deploy_agents.py script with validation only""" + + # Check if we're in the right directory + current_dir = os.getcwd() + expected_dir = "terraform/6_agents" + + if not current_dir.endswith(expected_dir): + print(f"❌ Please run this from the {expected_dir} directory") + print(f"Current directory: {current_dir}") + return False + + # Check if deploy_agents.py exists + if not os.path.exists("deploy_agents.py"): + print("❌ deploy_agents.py not found in current directory") + return False + + # Check if backend directory structure exists + backend_path = "../../backend" + if not os.path.exists(backend_path): + print(f"❌ Backend directory not found: {backend_path}") + return False + + # Check if all agent directories exist + agents = ["planner", "tagger", "reporter", "charter", "retirement"] + missing_agents = [] + + for agent in agents: + agent_dir = os.path.join(backend_path, agent) + agent_file = os.path.join(agent_dir, "agent.py") + + if not os.path.exists(agent_dir): + missing_agents.append(f"{agent} (directory)") + elif not os.path.exists(agent_file): + missing_agents.append(f"{agent} (agent.py)") + + if missing_agents: + print(f"❌ Missing agent components: {', '.join(missing_agents)}") + return False + + print("✅ All agent directories and files found") + + # Test import functionality (without actually deploying) + print("Testing Python imports...") + try: + # Test if we can import the script without running it + result = subprocess.run([ + sys.executable, "-c", + "import sys; sys.path.insert(0, '../../backend'); from utils import configure_runtime, create_agentcore_role; print('✅ Backend utils imported successfully')" + ], capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + print(result.stdout.strip()) + else: + print(f"❌ Import test failed: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + print("❌ Import test timed out") + return False + except Exception as e: + print(f"❌ Import test error: {e}") + return False + + print("\n✅ All validation tests passed!") + print("\nTo deploy agents, run:") + print(" terraform apply") + print("\nOr deploy individual agents:") + for agent in agents: + print(f" python deploy_agents.py {agent}") + + return True + +if __name__ == "__main__": + print("🧪 Testing deploy_agents.py setup...") + print("=" * 50) + + success = test_deploy_script() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/terraform/6_agentcore/variables.tf b/terraform/6_agentcore/variables.tf new file mode 100644 index 00000000..a6f37930 --- /dev/null +++ b/terraform/6_agentcore/variables.tf @@ -0,0 +1,81 @@ +variable "aws_region" { + description = "AWS region for resources" + type = string +} + +variable "aurora_cluster_arn" { + description = "ARN of the Aurora cluster from Part 5" + type = string +} + +variable "aurora_secret_arn" { + description = "ARN of the Secrets Manager secret from Part 5" + type = string +} + +variable "sqlalchemy_database_uri" { + description = "SQLAlchemy Database URI for connecting to the PostgreSQL database" + type = string + default = "value" +} + +variable "vector_bucket" { + description = "S3 Vectors bucket name from Part 3" + type = string +} + +variable "bedrock_model_id" { + description = "Bedrock model ID to use for agents" + type = string +} + +variable "bedrock_region" { + description = "AWS region for Bedrock" + type = string +} + +variable "sagemaker_endpoint" { + description = "SageMaker endpoint name from Part 2" + type = string + default = "alex-embedding-endpoint" +} + +variable "polygon_api_key" { + description = "Polygon.io API key for market data" + type = string +} + +variable "polygon_plan" { + description = "Polygon.io plan type (free or paid)" + type = string + default = "free" +} + +# LangFuse observability variables (optional) +variable "langfuse_public_key" { + description = "LangFuse public key for observability (optional)" + type = string + default = "" + sensitive = false +} + +variable "langfuse_secret_key" { + description = "LangFuse secret key for observability (optional)" + type = string + default = "" + sensitive = true +} + +variable "langfuse_host" { + description = "LangFuse host URL (optional)" + type = string + default = "https://us.cloud.langfuse.com" +} + +# OpenAI API key for tracing (required for OpenAI Agents SDK tracing) +variable "openai_api_key" { + description = "OpenAI API key for enabling tracing in OpenAI Agents SDK" + type = string + default = "" + sensitive = true +} \ No newline at end of file diff --git a/terraform/7_frontend/main.tf b/terraform/7_frontend/main.tf index 08e96b78..b5b50b5f 100644 --- a/terraform/7_frontend/main.tf +++ b/terraform/7_frontend/main.tf @@ -14,6 +14,12 @@ provider "aws" { region = var.aws_region } +# CloudFront requires ACM certificates to be in us-east-1 +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} + # Data sources data "aws_caller_identity" "current" {} @@ -35,7 +41,13 @@ data "terraform_remote_state" "agents" { } } + locals { + aliases = var.use_custom_domain && var.root_domain != "" ? [ + var.root_domain, + "www.${var.root_domain}" + ] : [] + name_prefix = "alex" common_tags = { @@ -210,6 +222,7 @@ resource "aws_lambda_function" "api" { AURORA_DATABASE = data.terraform_remote_state.database.outputs.database_name DEFAULT_AWS_REGION = var.aws_region + # SQLALCHEMY_DATABASE_URI = var.sqlalchemy_database_uri # SQS configuration from Part 6 SQS_QUEUE_URL = data.terraform_remote_state.agents.outputs.sqs_queue_url @@ -218,13 +231,12 @@ resource "aws_lambda_function" "api" { CLERK_ISSUER = var.clerk_issuer # CORS configuration - CORS_ORIGINS = "http://localhost:3000,https://${aws_cloudfront_distribution.main.domain_name}" + CORS_ORIGINS = var.use_custom_domain ? "https://${var.root_domain},https://www.${var.root_domain}" : "https://${aws_cloudfront_distribution.main.domain_name}" } } # Ensure Lambda waits for dependencies including CloudFront depends_on = [ - aws_iam_role_policy.api_lambda_aurora, aws_iam_role_policy.api_lambda_sqs, aws_iam_role_policy.api_lambda_invoke, aws_cloudfront_distribution.main @@ -385,7 +397,107 @@ resource "aws_cloudfront_distribution" "main" { } viewer_certificate { - cloudfront_default_certificate = true + # Use custom domain certificate when configured, otherwise use CloudFront default + cloudfront_default_certificate = var.use_custom_domain ? false : true + + # Custom domain configuration + acm_certificate_arn = var.use_custom_domain ? aws_acm_certificate_validation.site[0].certificate_arn : null + ssl_support_method = var.use_custom_domain ? "sni-only" : null + minimum_protocol_version = var.use_custom_domain ? "TLSv1.2_2021" : null + } + + # Add aliases for custom domain + aliases = var.use_custom_domain ? [var.root_domain, "www.${var.root_domain}"] : [] +} + + +# Optional: Custom domain configuration (only created when use_custom_domain = true) +data "aws_route53_zone" "root" { + count = var.use_custom_domain ? 1 : 0 + name = var.root_domain + private_zone = false +} + +resource "aws_acm_certificate" "site" { + count = var.use_custom_domain ? 1 : 0 + provider = aws.us_east_1 + domain_name = var.root_domain + subject_alternative_names = ["www.${var.root_domain}"] + validation_method = "DNS" + lifecycle { create_before_destroy = true } + tags = local.common_tags +} + +resource "aws_route53_record" "site_validation" { + for_each = var.use_custom_domain ? { + for dvo in aws_acm_certificate.site[0].domain_validation_options : + dvo.domain_name => dvo + } : {} + + zone_id = data.aws_route53_zone.root[0].zone_id + name = each.value.resource_record_name + type = each.value.resource_record_type + ttl = 300 + records = [each.value.resource_record_value] +} + +resource "aws_acm_certificate_validation" "site" { + count = var.use_custom_domain ? 1 : 0 + provider = aws.us_east_1 + certificate_arn = aws_acm_certificate.site[0].arn + validation_record_fqdns = [ + for r in aws_route53_record.site_validation : r.fqdn + ] +} + +resource "aws_route53_record" "alias_root" { + count = var.use_custom_domain ? 1 : 0 + zone_id = data.aws_route53_zone.root[0].zone_id + name = var.root_domain + type = "A" + + alias { + name = aws_cloudfront_distribution.main.domain_name + zone_id = aws_cloudfront_distribution.main.hosted_zone_id + evaluate_target_health = false + } +} + +resource "aws_route53_record" "alias_root_ipv6" { + count = var.use_custom_domain ? 1 : 0 + zone_id = data.aws_route53_zone.root[0].zone_id + name = var.root_domain + type = "AAAA" + + alias { + name = aws_cloudfront_distribution.main.domain_name + zone_id = aws_cloudfront_distribution.main.hosted_zone_id + evaluate_target_health = false + } +} + +resource "aws_route53_record" "alias_www" { + count = var.use_custom_domain ? 1 : 0 + zone_id = data.aws_route53_zone.root[0].zone_id + name = "www.${var.root_domain}" + type = "A" + + alias { + name = aws_cloudfront_distribution.main.domain_name + zone_id = aws_cloudfront_distribution.main.hosted_zone_id + evaluate_target_health = false } } +resource "aws_route53_record" "alias_www_ipv6" { + count = var.use_custom_domain ? 1 : 0 + zone_id = data.aws_route53_zone.root[0].zone_id + name = "www.${var.root_domain}" + type = "AAAA" + + alias { + name = aws_cloudfront_distribution.main.domain_name + zone_id = aws_cloudfront_distribution.main.hosted_zone_id + evaluate_target_health = false + } +} diff --git a/terraform/7_frontend/terraform.tfvars.example b/terraform/7_frontend/terraform.tfvars.example index 93dcd29b..bc1e411a 100644 --- a/terraform/7_frontend/terraform.tfvars.example +++ b/terraform/7_frontend/terraform.tfvars.example @@ -8,4 +8,4 @@ aws_region = "us-east-1" # The JWKS URL is: https://[your-instance].clerk.accounts.dev/.well-known/jwks.json # The issuer is: https://[your-instance].clerk.accounts.dev clerk_jwks_url = "https://engaging-feline-80.clerk.accounts.dev/.well-known/jwks.json" -clerk_issuer = "https://engaging-feline-80.clerk.accounts.dev" \ No newline at end of file +clerk_issuer = "https://engaging-feline-80.clerk.accounts.dev" diff --git a/terraform/7_frontend/variables.tf b/terraform/7_frontend/variables.tf index a9c832e7..bf8d9d93 100644 --- a/terraform/7_frontend/variables.tf +++ b/terraform/7_frontend/variables.tf @@ -14,4 +14,21 @@ variable "clerk_issuer" { description = "Clerk issuer URL (kept for Lambda environment)" type = string default = "" # Not actually used but kept for backwards compatibility +} + +variable "sqlalchemy_database_uri" { + description = "SQLAlchemy Database URI for connecting to the PostgreSQL database" + type = string +} + +variable "use_custom_domain" { + description = "Attach a custom domain to CloudFront" + type = bool + default = false +} + +variable "root_domain" { + description = "Apex domain name, e.g. mydomain.com" + type = string + default = "" } \ No newline at end of file diff --git a/terraform/pyproject.toml b/terraform/pyproject.toml index 6590b368..281e437c 100644 --- a/terraform/pyproject.toml +++ b/terraform/pyproject.toml @@ -2,4 +2,8 @@ name = "terraform" version = "0.1.0" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "boto3>=1.35.0", + "bedrock-agentcore-starter-toolkit>=0.1.0", + "bedrock-agentcore>=1.0.3", +]