From 087de435d7091398ebd907eb3dee62978472bf34 Mon Sep 17 00:00:00 2001 From: Nadeem Bulsara Date: Tue, 7 Apr 2026 14:52:35 -0700 Subject: [PATCH] add chat feature and healthomics backend --- .env.example | 3 + .gitignore | 5 + Procfile | 2 +- api/chat/__init__.py | 1 + api/chat/mcp_server.py | 369 +++++++++++++++++++++ api/chat/models.py | 32 ++ api/chat/routes.py | 58 ++++ api/chat/services.py | 397 +++++++++++++++++++++++ api/jobs/models.py | 22 +- api/jobs/routes.py | 62 ++-- api/jobs/services.py | 162 +++++++++- api/project/routes.py | 8 +- api/runs/models.py | 14 + api/runs/services.py | 112 ++++++- core/config.py | 42 +++ core/deps.py | 1 - core/lifespan.py | 9 +- main.py | 6 + pyproject.toml | 4 + requirements.txt | 128 +++++++- tests/api/test_chat.py | 602 +++++++++++++++++++++++++++++++++++ tests/api/test_jobs.py | 269 +++++++++------- tests/api/test_mcp_server.py | 560 ++++++++++++++++++++++++++++++++ tests/api/test_projects.py | 5 +- tests/api/test_runs.py | 16 +- uv.lock | 491 ++++++++++++++++++++++++---- 26 files changed, 3123 insertions(+), 257 deletions(-) create mode 100644 api/chat/__init__.py create mode 100644 api/chat/mcp_server.py create mode 100644 api/chat/models.py create mode 100644 api/chat/routes.py create mode 100644 api/chat/services.py create mode 100644 tests/api/test_chat.py create mode 100644 tests/api/test_mcp_server.py diff --git a/.env.example b/.env.example index 09777d85..bf475e62 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ AWS_ACCESS_KEY_ID=your_access_key_here AWS_SECRET_ACCESS_KEY=your_secret_key_here +#AWS_SESSION_TOKEN= AWS_REGION=us-east-1 # Database configuration @@ -25,6 +26,8 @@ OPENSEARCH_PASSWORD=your_opensearch_password # Storage System STORAGE_BACKEND=s3 STORAGE_ROOT_PATH=s3://my-storage-bucket +DEMUX_WORKFLOW_CONFIGS_BUCKET_URI=s3://yourbucket/demux-configs # AWS Secrets Manager (for production) # ENV_SECRETS=your-secrets-manager-name +CHAT_API_BASE_URL=http://localhost:3000/api/v1 diff --git a/.gitignore b/.gitignore index 78d8f41b..220aa3ac 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ wheels/ .elasticbeanstalk/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml + +# Test artifacts +.pytest_cache/ +htmlcov/ +*.log diff --git a/Procfile b/Procfile index 4b56f893..af18433e 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 --access-logfile - --error-logfile - +web: gunicorn main:app --workers 2 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 --graceful-timeout 120 --access-logfile - --error-logfile - diff --git a/api/chat/__init__.py b/api/chat/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/api/chat/__init__.py @@ -0,0 +1 @@ + diff --git a/api/chat/mcp_server.py b/api/chat/mcp_server.py new file mode 100644 index 00000000..dd9ee4ba --- /dev/null +++ b/api/chat/mcp_server.py @@ -0,0 +1,369 @@ +""" +MCP Server for NGS360 AI Chatbot. + +Exposes ~25 NGS360 REST API endpoints as MCP tools for the Strands Agent. +Each tool forwards the user's JWT for authorization and returns structured +error dicts on failure instead of raising exceptions. +""" + +from typing import Any +import httpx +from mcp.server.fastmcp import FastMCP + + +def _make_api_caller(jwt_token: str, api_base_url: str): + """ + Return a helper that makes HTTP requests to the NGS360 API + with the user's JWT in the Authorization header. + + On HTTP errors (4xx/5xx), returns {"error": status_code, "message": detail}. + On network errors, returns {"error": 503, "message": "Could not reach NGS360 API"}. + """ + headers = {"Authorization": f"Bearer {jwt_token}"} + base = api_base_url.rstrip("/") + + def call( + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + ) -> Any: + url = f"{base}{path}" + try: + resp = httpx.request( + method, + url, + headers=headers, + params=params, + json=json_body, + timeout=30.0, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as exc: + try: + detail = exc.response.json().get("detail", exc.response.text) + except Exception: + detail = exc.response.text + return {"error": exc.response.status_code, "message": detail} + except httpx.ConnectError: + return {"error": 503, "message": "Could not reach NGS360 API"} + except httpx.RequestError: + return {"error": 503, "message": "Could not reach NGS360 API"} + + return call + + + +def create_mcp_server(jwt_token: str, api_base_url: str) -> FastMCP: + """ + Create an MCP server instance with all NGS360 tools. + + Args: + jwt_token: The user's JWT for API authorization. + api_base_url: Base URL of the NGS360 API (e.g., "http://localhost:8000/api/v1"). + + Returns: + A FastMCP server ready to be used with Strands Agent. + """ + mcp = FastMCP("ngs360") + api = _make_api_caller(jwt_token, api_base_url) + + # ── Project tools ───────────────────────────────────────────────────── + + @mcp.tool() + def list_projects( + page: int = 1, + per_page: int = 20, + sort_by: str = "project_id", + sort_order: str = "asc", + ) -> Any: + """List all projects with pagination.""" + return api("GET", "/projects", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + @mcp.tool() + def get_project(project_id: str) -> Any: + """Get a single project by its project ID.""" + return api("GET", f"/projects/{project_id}") + + @mcp.tool() + def search_projects( + query: str, + page: int = 1, + per_page: int = 20, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Any: + """Search projects by project_id or name.""" + return api("GET", "/projects/search", params={ + "query": query, "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + # ── Run tools ───────────────────────────────────────────────────────── + + @mcp.tool() + def list_runs( + page: int = 1, + per_page: int = 20, + sort_by: str = "barcode", + sort_order: str = "asc", + ) -> Any: + """List all sequencing runs with pagination.""" + return api("GET", "/runs", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + @mcp.tool() + def get_run(run_barcode: str) -> Any: + """Get a sequencing run by its barcode.""" + return api("GET", f"/runs/{run_barcode}") + + @mcp.tool() + def search_runs( + query: str, + page: int = 1, + per_page: int = 20, + sort_by: str = "barcode", + sort_order: str = "asc", + ) -> Any: + """Search sequencing runs by barcode or experiment name.""" + return api("GET", "/runs/search", params={ + "query": query, "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + @mcp.tool() + def get_run_samplesheet(run_barcode: str) -> Any: + """Get the sample sheet for a sequencing run.""" + return api("GET", f"/runs/{run_barcode}/samplesheet") + + @mcp.tool() + def get_run_metrics(run_barcode: str) -> Any: + """Get demultiplexing metrics for a sequencing run.""" + return api("GET", f"/runs/{run_barcode}/metrics") + + # ── Sample tools ────────────────────────────────────────────────────── + + @mcp.tool() + def get_project_samples( + project_id: str, + page: int = 1, + per_page: int = 20, + sort_by: str = "sample_id", + sort_order: str = "asc", + ) -> Any: + """List samples belonging to a project.""" + return api("GET", f"/projects/{project_id}/samples", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + # ── File tools ──────────────────────────────────────────────────────── + + @mcp.tool() + def list_files_by_entity( + entity_type: str, + entity_id: str, + include_archived: bool = False, + page: int = 1, + per_page: int = 100, + ) -> Any: + """List files associated with an entity (PROJECT, RUN, SAMPLE, QCRECORD, etc.).""" + return api("GET", "/files", params={ + "entity_type": entity_type, "entity_id": entity_id, + "include_archived": include_archived, + "page": page, "per_page": per_page, + }) + + @mcp.tool() + def browse_s3_files(uri: str) -> Any: + """Browse files and folders at an S3 URI.""" + return api("GET", "/files/list", params={"uri": uri}) + + @mcp.tool() + def download_file(path: str) -> Any: + """Get a download link / content for a file at the given S3 URI.""" + return api("GET", "/files/download", params={"path": path}) + + # ── Job tools ───────────────────────────────────────────────────────── + + @mcp.tool() + def list_jobs( + skip: int = 0, + limit: int = 100, + user: str | None = None, + status_filter: str | None = None, + sort_by: str = "submitted_on", + sort_order: str = "desc", + ) -> Any: + """List batch jobs with optional filtering.""" + params: dict[str, Any] = { + "skip": skip, "limit": limit, + "sort_by": sort_by, "sort_order": sort_order, + } + if user: + params["user"] = user + if status_filter: + params["status_filter"] = status_filter + return api("GET", "/jobs", params=params) + + @mcp.tool() + def get_job(job_id: str) -> Any: + """Get details of a specific batch job by UUID.""" + return api("GET", f"/jobs/{job_id}") + + @mcp.tool() + def get_job_log(job_id: str) -> Any: + """Get the log output for a batch job.""" + return api("GET", f"/jobs/{job_id}/log") + + # ── QC Metrics tools ────────────────────────────────────────────────── + + @mcp.tool() + def search_qc_records( + project_id: str | None = None, + sequencing_run_barcode: str | None = None, + workflow_run_id: str | None = None, + latest: bool = True, + page: int = 1, + per_page: int = 100, + ) -> Any: + """Search QC metric records with optional filters.""" + params: dict[str, Any] = { + "latest": latest, "page": page, "per_page": per_page, + } + if project_id: + params["project_id"] = project_id + if sequencing_run_barcode: + params["sequencing_run_barcode"] = sequencing_run_barcode + if workflow_run_id: + params["workflow_run_id"] = workflow_run_id + return api("GET", "/qcmetrics/search", params=params) + + @mcp.tool() + def get_qc_record(qcrecord_id: str) -> Any: + """Get a specific QC record by its ID.""" + return api("GET", f"/qcmetrics/{qcrecord_id}") + + # ── Workflow tools ──────────────────────────────────────────────────── + + @mcp.tool() + def list_workflows( + page: int = 1, + per_page: int = 20, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Any: + """List all workflows with pagination.""" + return api("GET", "/workflows", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + @mcp.tool() + def get_workflow(workflow_id: str) -> Any: + """Get a single workflow by its ID.""" + return api("GET", f"/workflows/{workflow_id}") + + @mcp.tool() + def list_workflow_runs( + workflow_id: str, + page: int = 1, + per_page: int = 20, + sort_by: str = "created_at", + sort_order: str = "desc", + ) -> Any: + """List execution runs for a workflow.""" + return api("GET", f"/workflows/{workflow_id}/runs", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + # ── Pipeline tools ──────────────────────────────────────────────────── + + @mcp.tool() + def list_pipelines( + page: int = 1, + per_page: int = 20, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Any: + """List all pipelines with pagination.""" + return api("GET", "/pipelines", params={ + "page": page, "per_page": per_page, + "sort_by": sort_by, "sort_order": sort_order, + }) + + @mcp.tool() + def get_pipeline(pipeline_id: str) -> Any: + """Get a single pipeline by its ID.""" + return api("GET", f"/pipelines/{pipeline_id}") + + # ── Search tools ────────────────────────────────────────────────────── + + @mcp.tool() + def cross_entity_search(query: str, n_results: int = 5) -> Any: + """Search across all entity types (projects, runs, samples, etc.).""" + return api("GET", "/search", params={ + "query": query, "n_results": n_results, + }) + + # ── Action tools (mutations) ────────────────────────────────────────── + + @mcp.tool() + def list_demux_configs() -> Any: + """List available demultiplexing workflow configurations. Returns a list of workflow config IDs that can be used with submit_demux_workflow.""" + return api("GET", "/runs/demultiplex") + + @mcp.tool() + def get_demux_config( + workflow_id: str, + run_barcode: str | None = None, + ) -> Any: + """Get a specific demultiplexing workflow configuration by ID. Optionally pass a run_barcode to prepopulate the s3_run_folder_path input.""" + params: dict[str, Any] = {} + if run_barcode: + params["run_barcode"] = run_barcode + return api("GET", f"/runs/demultiplex/{workflow_id}", params=params) + + @mcp.tool() + def submit_demux_workflow( + workflow_id: str, + run_barcode: str, + inputs: dict[str, Any] | None = None, + ) -> Any: + """Submit a demultiplexing workflow job for a sequencing run. This is a write operation — confirm with the user before calling.""" + body: dict[str, Any] = { + "workflow_id": workflow_id, + "run_barcode": run_barcode, + } + if inputs: + body["inputs"] = inputs + return api("POST", "/runs/demultiplex", json_body=body) + + @mcp.tool() + def submit_pipeline_job( + project_id: str, + action: str, + platform: str, + project_type: str, + reference: str | None = None, + auto_release: bool = False, + ) -> Any: + """Submit a pipeline job for a project. This is a write operation — confirm with the user before calling.""" + body: dict[str, Any] = { + "action": action, + "platform": platform, + "project_type": project_type, + "auto_release": auto_release, + } + if reference: + body["reference"] = reference + return api("POST", f"/projects/{project_id}/actions/submit", json_body=body) + + return mcp diff --git a/api/chat/models.py b/api/chat/models.py new file mode 100644 index 00000000..939ab0a1 --- /dev/null +++ b/api/chat/models.py @@ -0,0 +1,32 @@ +""" +Models for the Chat API +""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class ChatRequest(BaseModel): + """Incoming chat message from the frontend.""" + + model_config = ConfigDict(extra="forbid") + + message: str + conversation_id: str | None = None + stream: bool = True + + +class ChatResponse(BaseModel): + """Non-streaming response containing the full agent reply.""" + + response: str + conversation_id: str + + +class ChatStreamEvent(BaseModel): + """A single SSE event in the streaming response.""" + + event: Literal["text", "error", "done"] + data: str + conversation_id: str | None = None diff --git a/api/chat/routes.py b/api/chat/routes.py new file mode 100644 index 00000000..92eb3378 --- /dev/null +++ b/api/chat/routes.py @@ -0,0 +1,58 @@ +""" +Chat API routes. + +Provides the POST /chat endpoint for the NGS360 AI Chatbot. +Supports both SSE streaming and synchronous JSON responses. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse, StreamingResponse + +from api.auth.deps import CurrentUser, oauth2_scheme +from api.chat.models import ChatRequest, ChatResponse +from api.chat.services import process_message + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/chat", tags=["Chat Endpoints"]) + + +@router.post("", response_model=None) +async def chat( + request: ChatRequest, + current_user: CurrentUser, + token: str = Depends(oauth2_scheme), +) -> StreamingResponse | ChatResponse | JSONResponse: + """ + Send a message to the AI chatbot. + + Streams SSE by default, or returns JSON if stream=false. + The user's JWT is forwarded to the agent so all NGS360 API + calls respect the user's existing permissions. + """ + try: + result = await process_message( + user_jwt=token, + user_id=str(current_user.id), + message=request.message, + conversation_id=request.conversation_id, + stream=request.stream, + ) + + if request.stream: + return StreamingResponse( + result, + media_type="text/event-stream", + ) + + return result + except HTTPException: + raise + except Exception as e: + logger.exception("Unhandled error in chat endpoint: %s", e) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "An error occurred while processing your message."}, + ) diff --git a/api/chat/services.py b/api/chat/services.py new file mode 100644 index 00000000..6e545116 --- /dev/null +++ b/api/chat/services.py @@ -0,0 +1,397 @@ +""" +Agent service for the NGS360 AI Chatbot. + +Orchestrates Strands Agent creation with MCP tools, AgentCore Memory +for conversation persistence, and message processing (streaming + sync). +""" + +import asyncio +import logging +import uuid +from collections.abc import AsyncGenerator + +from fastapi import HTTPException, status + +from core.config import get_settings +from api.chat.models import ChatResponse, ChatStreamEvent +from api.chat.mcp_server import create_mcp_server + +logger = logging.getLogger(__name__) + +# In-memory conversation history store: {conversation_id: [messages]} +# Each message is a dict with "role" and "content" keys. +# This is cleared on server restart. +_conversation_store: dict[str, list[dict]] = {} + + +def build_system_prompt() -> str: + """Return the agent system prompt describing the NGS360 domain and behavior.""" + return """You are a helpful genomics data assistant for the NGS360 platform. \ +You help users query and manage Next Generation Sequencing (NGS) data through \ +natural language. + +IMPORTANT: Do NOT include tags or any internal reasoning in your \ +responses. Respond directly to the user with clear, helpful answers. + +## Domain Knowledge + +NGS360 tracks the lifecycle of sequencing projects: + +- **Projects** contain samples and are the top-level organizational unit. +- **Samples** are biological specimens registered within a project. +- **Sequencing Runs** represent a single run on a sequencing instrument, \ +identified by a barcode. +- **QC Metrics** are quality-control records collected per pipeline execution. \ +They can be workflow-level, single-sample, or paired-sample (e.g. tumor/normal). +- **Files** are tracked with a many-to-many association pattern — a file can \ +belong to multiple projects, runs, samples, or QC records. +- **Workflows** define bioinformatics analysis steps (e.g. alignment, variant \ +calling). Each workflow can have multiple execution runs. +- **Pipelines** are higher-level definitions that orchestrate one or more \ +workflows. +- **Jobs** represent batch processing tasks submitted to AWS Batch. +- **Search** provides cross-entity full-text queries across all entity types. + +## Behavior Guidelines + +1. **Prefer reads before writes.** Always look up data before attempting to \ +create or modify resources. +2. **Confirm before mutations.** Before submitting a demux workflow or pipeline \ +job, summarize what you are about to do and ask the user to confirm. +3. **Chain tool calls** as needed to assemble a complete answer. For example, \ +to find QC metrics for a project's samples, first list the project's samples, \ +then search QC records. +4. **Be transparent about limitations.** If you cannot access certain data with \ +the available tools, explain what is unavailable and suggest alternatives. +5. **Format responses with Markdown** for readability. Use tables for tabular \ +data, code blocks for identifiers or paths, and lists for enumerations. +6. **Be concise but thorough.** Provide the information the user needs without \ +unnecessary verbosity.""" + + +def _create_mcp_tools(jwt_token: str) -> list: + """ + Create MCP tools from the NGS360 MCP server for use with Strands Agent. + + Uses the MCPClient with streamable-http transport to connect to the + in-process FastMCP server. Returns a list of tool objects the agent + can use. + """ + settings = get_settings() + mcp_server = create_mcp_server(jwt_token, settings.CHAT_API_BASE_URL) + + # Extract tools directly from the FastMCP server's tool registry. + # Each MCP tool wraps an NGS360 API call with the user's JWT. + from strands import tool as strands_tool + + tools = [] + for name, mcp_tool in mcp_server._tool_manager._tools.items(): + fn = mcp_tool.fn + # Wrap the MCP tool function as a Strands @tool + wrapped = strands_tool(fn) + tools.append(wrapped) + + return tools + + +def _create_session_manager(conversation_id: str | None, user_id: str): + """ + Create an AgentCore Memory session manager for conversation persistence. + + Returns (session_manager, conversation_id) tuple. If AgentCore Memory + is not configured (AGENTCORE_MEMORY_ID is None), returns (None, conv_id). + """ + settings = get_settings() + memory_id = settings.AGENTCORE_MEMORY_ID + + if not memory_id: + # No memory configured — generate a conversation ID but skip memory + conv_id = conversation_id or str(uuid.uuid4()) + return None, conv_id + + conv_id = conversation_id or str(uuid.uuid4()) + + try: + from bedrock_agentcore.memory.integrations.strands.config import ( + AgentCoreMemoryConfig, + ) + from bedrock_agentcore.memory.integrations.strands.session_manager import ( + AgentCoreMemorySessionManager, + ) + + config = AgentCoreMemoryConfig( + memory_id=memory_id, + session_id=conv_id, + actor_id=user_id, + ) + session_manager = AgentCoreMemorySessionManager( + agentcore_memory_config=config, + region_name=settings.BEDROCK_REGION, + ) + return session_manager, conv_id + except Exception as e: + logger.warning("Failed to initialize AgentCore Memory: %s", e) + return None, conv_id + + +def create_agent(jwt_token: str, conversation_id: str | None, user_id: str): + """ + Create a Strands Agent with MCP tools and optional conversation history. + + Args: + jwt_token: The user's JWT for API authorization. + conversation_id: Existing conversation ID, or None for new. + user_id: The authenticated user's ID for memory scoping. + + Returns: + (agent, conversation_id, memory_warning) tuple. + """ + from strands import Agent + from strands.models import BedrockModel + import boto3 + + settings = get_settings() + + # Create a fresh boto3 session per request to pick up current credentials + session = boto3.Session(region_name=settings.BEDROCK_REGION) + + model = BedrockModel( + model_id=settings.BEDROCK_MODEL_ID, + boto_session=session, + ) + + tools = _create_mcp_tools(jwt_token) + + # Resolve conversation ID + conv_id = conversation_id or str(uuid.uuid4()) + + # Try AgentCore Memory first if configured + session_manager = None + memory_warning = None + + if settings.AGENTCORE_MEMORY_ID: + session_manager, conv_id = _create_session_manager( + conversation_id, user_id + ) + if session_manager is None: + memory_warning = ( + "Conversation history could not be loaded. " + "Responding without prior context." + ) + + # Load in-memory conversation history + messages = _conversation_store.get(conv_id, []) + + agent = Agent( + model=model, + tools=tools, + system_prompt=build_system_prompt(), + session_manager=session_manager, + callback_handler=None, + messages=messages if messages else None, + ) + + return agent, conv_id, memory_warning + + + +async def process_message( + user_jwt: str, + user_id: str, + message: str, + conversation_id: str | None = None, + stream: bool = True, +) -> AsyncGenerator[str, None] | ChatResponse: + """ + Process a user message through the Strands Agent. + + Creates/loads conversation context from AgentCore Memory, + initializes the agent with MCP tools, and returns the response. + + For stream=True, returns an AsyncGenerator yielding SSE-formatted strings. + For stream=False, returns a ChatResponse with the full reply. + + Raises: + HTTPException 503: Bedrock service unavailable + HTTPException 504: Request timed out + HTTPException 500: Unexpected agent error + """ + settings = get_settings() + + try: + agent, conv_id, memory_warning = create_agent( + jwt_token=user_jwt, + conversation_id=conversation_id, + user_id=user_id, + ) + except Exception as e: + logger.error("Failed to create agent: %s", e) + # Check if it's a Bedrock connectivity issue + if _is_bedrock_error(e): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI service is temporarily unavailable.", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while processing your message.", + ) + + if stream: + return _stream_response( + agent, message, conv_id, memory_warning, settings + ) + else: + return await _sync_response( + agent, message, conv_id, memory_warning, settings + ) + + +def _is_bedrock_error(exc: Exception) -> bool: + """Check if an exception is a Bedrock service error.""" + try: + from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + NoCredentialsError, + ) + return isinstance(exc, (ClientError, EndpointConnectionError, NoCredentialsError)) + except ImportError: + return False + + +def _is_bedrock_error_in_chain(exc: Exception) -> bool: + """Check if a Bedrock error exists anywhere in the exception chain.""" + current = exc + while current is not None: + if _is_bedrock_error(current): + return True + current = current.__cause__ if current.__cause__ else current.__context__ + if current is exc: + break # avoid infinite loop + return False + + +def _save_conversation(conversation_id: str, agent): + """Save the agent's message history to the in-memory store.""" + try: + _conversation_store[conversation_id] = list(agent.messages) + except Exception as e: + logger.warning("Failed to save conversation history: %s", e) + + +def _stream_response( + agent, + message: str, + conversation_id: str, + memory_warning: str | None, + settings, +) -> AsyncGenerator[str, None]: + """Stream agent response as SSE events.""" + + async def event_generator() -> AsyncGenerator[str, None]: + # Send memory warning as first event if applicable + if memory_warning: + evt = ChatStreamEvent( + event="text", + data=f"⚠️ {memory_warning}\n\n", + conversation_id=conversation_id, + ) + yield f"event: text\ndata: {evt.model_dump_json()}\n\n" + + try: + # Use a deadline to enforce the timeout across the + # entire streaming session rather than per-chunk. + deadline = asyncio.get_event_loop().time() + settings.CHAT_TIMEOUT_SECONDS + stream = agent.stream_async(message) + + async for event in stream: + if asyncio.get_event_loop().time() > deadline: + raise asyncio.TimeoutError() + + if "data" in event: + text_chunk = event["data"] + if text_chunk: + evt = ChatStreamEvent( + event="text", + data=text_chunk, + conversation_id=conversation_id, + ) + yield f"event: text\ndata: {evt.model_dump_json()}\n\n" + + except asyncio.TimeoutError: + err = ChatStreamEvent( + event="error", + data="Request timed out.", + conversation_id=conversation_id, + ) + yield f"event: error\ndata: {err.model_dump_json()}\n\n" + return + + except Exception as e: + logger.error("Streaming error: %s", e) + if _is_bedrock_error_in_chain(e): + err_msg = "AI service is temporarily unavailable." + else: + err_msg = "An error occurred while processing your message." + err = ChatStreamEvent( + event="error", + data=err_msg, + conversation_id=conversation_id, + ) + yield f"event: error\ndata: {err.model_dump_json()}\n\n" + return + + # Send done event + _save_conversation(conversation_id, agent) + done = ChatStreamEvent( + event="done", + data="", + conversation_id=conversation_id, + ) + yield f"event: done\ndata: {done.model_dump_json()}\n\n" + + return event_generator() + + +async def _sync_response( + agent, + message: str, + conversation_id: str, + memory_warning: str | None, + settings, +) -> ChatResponse: + """Run agent synchronously and return a complete ChatResponse.""" + try: + result = await asyncio.wait_for( + agent.invoke_async(message), + timeout=settings.CHAT_TIMEOUT_SECONDS, + ) + response_text = str(result) + + _save_conversation(conversation_id, agent) + + if memory_warning: + response_text = f"⚠️ {memory_warning}\n\n{response_text}" + + return ChatResponse( + response=response_text, + conversation_id=conversation_id, + ) + + except asyncio.TimeoutError: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Request timed out.", + ) + except Exception as e: + logger.error("Agent error: %s", e) + if _is_bedrock_error_in_chain(e): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI service is temporarily unavailable.", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while processing your message.", + ) diff --git a/api/jobs/models.py b/api/jobs/models.py index e64cc5cc..686af6b6 100644 --- a/api/jobs/models.py +++ b/api/jobs/models.py @@ -2,6 +2,7 @@ Models for the Jobs API """ from typing import Any, Optional +import uuid from datetime import datetime, timezone from enum import Enum from sqlmodel import SQLModel, Field @@ -23,11 +24,12 @@ class BatchJob(SQLModel, table=True): """ This class/table represents a batch job """ - id: str = Field(max_length=255, primary_key=True) + id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True) name: str = Field(max_length=255) command: str = Field(max_length=1000) user: str = Field(max_length=100) submitted_on: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + aws_job_id: str | None = Field(default=None, max_length=255) log_stream_name: str | None = Field(default=None, max_length=255) status: JobStatus = Field(default=JobStatus.SUBMITTED) viewed: bool = Field(default=False) @@ -35,20 +37,34 @@ class BatchJob(SQLModel, table=True): model_config = ConfigDict(from_attributes=True) +class BatchJobCreate(SQLModel): + """Schema for creating a new batch job""" + name: str + command: str + user: str + aws_job_id: Optional[str] = None + log_stream_name: Optional[str] = None + status: Optional[JobStatus] = JobStatus.SUBMITTED + + class BatchJobUpdate(SQLModel): """Schema for updating a batch job""" + name: Optional[str] = None + command: Optional[str] = None + aws_job_id: Optional[str] = None log_stream_name: Optional[str] = None - status: Optional[JobStatus] = None + status: Optional[JobStatus] = JobStatus.SUBMITTED viewed: Optional[bool] = None class BatchJobPublic(SQLModel): """Schema for returning a batch job""" - id: str + id: uuid.UUID name: str command: str user: str submitted_on: datetime + aws_job_id: str | None log_stream_name: str | None status: JobStatus viewed: bool diff --git a/api/jobs/routes.py b/api/jobs/routes.py index 615d7832..322e822a 100644 --- a/api/jobs/routes.py +++ b/api/jobs/routes.py @@ -20,6 +20,7 @@ JobStatus ) from api.jobs import services +import uuid router = APIRouter(prefix="/jobs", tags=["Job Endpoints"]) @@ -72,6 +73,41 @@ def submit_job( return BatchJobPublic.model_validate(job) +@router.put( + "", + response_model=BatchJobPublic, + tags=["Job Endpoints"], +) +def find_and_update_job( + session: SessionDep, + job_update: BatchJobUpdate, +) -> BatchJobPublic: + """ + Find and Update a batch job. + + Args: + session: Database session + job_update: Job update data + + Returns: + Updated job information + """ + # Make sure there is an aws_job_id to find the job + if not job_update.aws_job_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="aws_job_id is required" + ) + job = services.get_batch_job_by_aws_id(session, job_update.aws_job_id) + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No job found with aws_job_id {job_update.aws_job_id}" + ) + updated_job = services.update_batch_job(session, job.id, job_update) + return BatchJobPublic.model_validate(updated_job) + + @router.get( "", response_model=BatchJobsPublic, @@ -127,24 +163,19 @@ def get_jobs( ) def get_job( session: SessionDep, - job_id: str, + job_id: uuid.UUID, ) -> BatchJobPublic: """ Retrieve information about a specific batch job. Args: session: Database session - job_id: string representation of the job UUID + job_id: Job UUID Returns: Job information """ job = services.get_batch_job(session, job_id) - if not job: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No job found with id {job_id}" - ) return BatchJobPublic.model_validate(job) @@ -155,11 +186,11 @@ def get_job( ) def update_job( session: SessionDep, - job_id: str, + job_id: uuid.UUID, job_update: BatchJobUpdate, ) -> BatchJobPublic: """ - Find and Update a batch job. + Update a batch job. Args: session: Database session @@ -169,15 +200,8 @@ def update_job( Returns: Updated job information """ - # Make sure there is an id to find the job - job = services.get_batch_job(session, job_id) - if not job: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No job found with id {job_id}" - ) - updated_job = services.update_batch_job(session, job, job_update) - return BatchJobPublic.model_validate(updated_job) + job = services.update_batch_job(session, job_id, job_update) + return BatchJobPublic.model_validate(job) @router.get( @@ -187,7 +211,7 @@ def update_job( ) def get_job_log( session: SessionDep, - job_id: str, + job_id: uuid.UUID, ) -> list[str]: """ Retrieve log for a specific batch job. diff --git a/api/jobs/services.py b/api/jobs/services.py index a840770f..06f9a50d 100644 --- a/api/jobs/services.py +++ b/api/jobs/services.py @@ -12,12 +12,47 @@ from api.jobs.models import ( BatchJob, + BatchJobCreate, BatchJobUpdate, JobStatus ) -def get_batch_job(session: Session, job_id: str) -> BatchJob | None: +def create_batch_job(session: Session, job_in: BatchJobCreate) -> BatchJob: + """ + Create a new batch job. + + Args: + session: Database session + job_in: Job creation data + + Returns: + Created BatchJob instance + """ + job = BatchJob.model_validate(job_in) + session.add(job) + session.commit() + session.refresh(job) + logger.info(f"Created batch job: {job.id}") + return job + + +def get_batch_job_by_aws_id(session: Session, aws_job_id: str) -> BatchJob | None: + """ + Find a batch job by its AWS job ID. + + Args: + session: Database session + aws_job_id: AWS job ID to search for + + Returns: + BatchJob instance if found, otherwise None + """ + statement = select(BatchJob).where(BatchJob.aws_job_id == aws_job_id) + return session.exec(statement).first() + + +def get_batch_job(session: Session, job_id: uuid.UUID) -> BatchJob: """ Retrieve a batch job by ID. @@ -26,9 +61,18 @@ def get_batch_job(session: Session, job_id: str) -> BatchJob | None: job_id: Job UUID Returns: - BatchJob instance or None if not found + BatchJob instance + + Raises: + HTTPException: If job not found """ - return session.get(BatchJob, job_id) + job = session.get(BatchJob, job_id) + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Batch job {job_id} not found" + ) + return job def get_batch_jobs( @@ -79,7 +123,7 @@ def get_batch_jobs( def update_batch_job( session: Session, - job: BatchJob, + job_id: uuid.UUID, job_update: BatchJobUpdate ) -> BatchJob: """ @@ -87,7 +131,7 @@ def update_batch_job( Args: session: Database session - job: BatchJob instance to update + job_id: Job UUID job_update: Job update data Returns: @@ -96,6 +140,8 @@ def update_batch_job( Raises: HTTPException: If job not found """ + job = get_batch_job(session, job_id) + update_data = job_update.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(job, key, value) @@ -103,7 +149,7 @@ def update_batch_job( session.add(job) session.commit() session.refresh(job) - logger.info(f"Updated batch job: {job.id}") + logger.info(f"Updated batch job: {job_id}") return job @@ -133,6 +179,7 @@ def submit_batch_job( f"Submitting job '{job_name}' to AWS Batch queue '{job_queue}' " f"with definition '{job_def}'" ) + logger.info(f"Container overrides: {container_overrides}") # Extract command from container overrides (expecting list) command = " ".join(container_overrides.get("command", [])) @@ -143,7 +190,6 @@ def submit_batch_job( container_overrides["environment"].append( {"name": "NGS360_API_ENDPOINT", "value": settings.client_origin} ) - logger.info(f"Container overrides: {container_overrides}") try: batch_client = boto3.client("batch", region_name=settings.AWS_REGION) @@ -160,19 +206,20 @@ def submit_batch_job( detail=f"Failed to submit job to AWS Batch: {err}", ) from err - job = BatchJob( - id=response.get("jobId"), + # Create database record with AWS job information + job_create = BatchJobCreate( name=job_name, command=command, user=user, + aws_job_id=response.get("jobId"), status=JobStatus.SUBMITTED ) - session.add(job) - session.commit() - session.refresh(job) - logger.info(f"Created batch job: {job.id}") - return job + batch_job = create_batch_job(session, job_create) + logger.info(f"Created database record for Job {response.get('jobId')} " + f"/ AWS Batch job {batch_job.aws_job_id}") + + return batch_job def get_batch_job_log(session: Session, job_id: uuid.UUID) -> list[str]: @@ -185,10 +232,10 @@ def get_batch_job_log(session: Session, job_id: uuid.UUID) -> list[str]: Returns: Log output as a list of strings """ - job = session.get(BatchJob, job_id) + job = get_batch_job(session, job_id) - if not job or not job.log_stream_name: - logger.warning(f"Job {job_id} not available or does not have a log stream name") + if not job.aws_job_id or not job.log_stream_name: + logger.warning(f"Job {job_id} does not have AWS job ID or log stream name") return [] log_group = "/aws/batch/job" @@ -232,3 +279,84 @@ def get_log_events(log_group, log_stream_name, start_time=None, end_time=None): break kwargs['nextToken'] = next_forward_token return events + +def submit_healthomics_run( + session: Session, + workflow_id: str, + role_arn: str, + output_uri: str, + parameters: Dict[str, Any], + run_name: str | None = None, + workflow_version_name: str | None = None, + storage_type: str = "DYNAMIC", + storage_capacity: int | None = None, +) -> BatchJob: + """ + Submit a workflow run to AWS HealthOmics and create a database record. + + Args: + session: Database session + workflow_id: HealthOmics workflow ID + role_arn: IAM role ARN for the run + output_uri: S3 URI for run outputs + parameters: Workflow input parameters + run_name: Optional human-readable run name + workflow_version_name: Optional workflow version (e.g. "v1.4.0") + storage_type: STATIC or DYNAMIC (default DYNAMIC) + storage_capacity: Storage in GB (required for STATIC) + + Returns: + BatchJob record tracking the HealthOmics run + """ + settings = get_settings() + + start_run_kwargs: Dict[str, Any] = { + "workflowId": workflow_id, + "roleArn": role_arn, + "outputUri": output_uri, + "parameters": parameters, + "storageType": storage_type, + } + if run_name: + start_run_kwargs["name"] = run_name + if workflow_version_name: + start_run_kwargs["workflowVersionName"] = workflow_version_name + if storage_type == "STATIC" and storage_capacity: + start_run_kwargs["storageCapacity"] = storage_capacity + + logger.info( + f"Submitting HealthOmics run for workflow {workflow_id} " + f"(version={workflow_version_name})" + ) + + try: + omics_client = boto3.client("omics", region_name=settings.AWS_REGION) + response = omics_client.start_run(**start_run_kwargs) + except botocore.exceptions.ClientError as err: + logger.error(f"Failed to submit HealthOmics run: {err}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to submit HealthOmics run: {err}", + ) from err + + omics_run_id = response.get("id", "") + command_summary = f"omics:start-run --workflow-id {workflow_id}" + if workflow_version_name: + command_summary += f" --version {workflow_version_name}" + + job_create = BatchJobCreate( + name=run_name or f"omics-{workflow_id}-{omics_run_id}", + command=command_summary, + user="healthomics", + aws_job_id=omics_run_id, + status=JobStatus.SUBMITTED, + ) + + batch_job = create_batch_job(session, job_create) + logger.info( + f"Created database record for HealthOmics run {omics_run_id} " + f"(job record {batch_job.id})" + ) + + return batch_job + diff --git a/api/project/routes.py b/api/project/routes.py index c47fde3b..f12f4a35 100755 --- a/api/project/routes.py +++ b/api/project/routes.py @@ -3,8 +3,8 @@ """ from typing import Literal -from fastapi import APIRouter, Query, status -from core.deps import SessionDep, OpenSearchDep, S3ClientDep +from fastapi import APIRouter, Query, status, Depends +from core.deps import SessionDep, OpenSearchDep, get_s3_client from api.auth.deps import CurrentUser from api.jobs.models import BatchJobPublic from api.project.deps import ProjectDep @@ -254,7 +254,7 @@ def submit_pipeline_job( request: ActionSubmitRequest, current_user: CurrentUser, session: SessionDep, - s3_client: S3ClientDep, + s3_client=Depends(get_s3_client), ) -> BatchJobPublic: """ Submit a pipeline job to AWS Batch for a project. @@ -308,7 +308,7 @@ def ingest_vendor_data( session: SessionDep, project: ProjectDep, user: CurrentUser, - s3_client: S3ClientDep, + s3_client=Depends(get_s3_client), files_uri: str = Query( ..., description="Source Bucket/Prefix of the data to be ingested" ), diff --git a/api/runs/models.py b/api/runs/models.py index fa013e99..1f7f7ff9 100644 --- a/api/runs/models.py +++ b/api/runs/models.py @@ -294,6 +294,18 @@ class DemuxWorkflowTag(BaseModel): name: str +class HealthOmicsConfig(BaseModel): + """Configuration for submitting a workflow run to AWS HealthOmics.""" + workflow_id: str + workflow_version_name: Optional[str] = None + role_arn: str + output_uri: str + storage_type: str = "DYNAMIC" + storage_capacity: Optional[int] = None + run_name: Optional[str] = None + parameters: Optional[Dict[str, Any]] = None + + class DemuxWorkflowConfig(BaseModel): version: int workflow_id: str @@ -303,12 +315,14 @@ class DemuxWorkflowConfig(BaseModel): help: str tags: List[DemuxWorkflowTag] aws_batch: Optional[AwsBatchConfig] = None + healthomics: Optional[HealthOmicsConfig] = None class DemuxWorkflowSubmitBody(BaseModel): workflow_id: str run_barcode: str inputs: Dict[str, Any] + backend: Optional[str] = None # --------------------------------------------------------------------------- diff --git a/api/runs/services.py b/api/runs/services.py index 1690120b..09e5bcef 100644 --- a/api/runs/services.py +++ b/api/runs/services.py @@ -40,7 +40,7 @@ from api.search.models import ( SearchDocument, ) -from api.jobs.services import submit_batch_job +from api.jobs.services import submit_batch_job, submit_healthomics_run from api.jobs.models import BatchJobPublic from api.settings.services import get_setting_value @@ -639,36 +639,70 @@ def submit_demux_job( session: Session, workflow_body: DemuxWorkflowSubmitBody, username: str, s3_client=None ) -> BatchJobPublic: """ - Submit an AWS Batch job for the specified demultiplex workflow. + Submit a demultiplex workflow job to AWS Batch or HealthOmics. + + The backend is chosen by: + 1. Explicit ``workflow_body.backend`` ("aws_batch" or "healthomics") + 2. Auto-detect from the YAML config (healthomics preferred when both present) Args: session: Database session workflow_body: The demultiplex workflow execution request containing workflow_id, - run_barcode, and inputs + run_barcode, inputs, and optional backend + username: Authenticated user's username s3_client: Optional boto3 S3 client Returns: - BatchJobPublic: The created batch job with AWS job information. + BatchJobPublic: The created job record. """ - # Get tool config tool_config = get_demux_workflow_config( session=session, workflow_id=workflow_body.workflow_id, s3_client=s3_client ) - # Interpolate inputs with aws_batch schema definition + # Determine backend + backend = workflow_body.backend + if backend is None: + if tool_config.healthomics: + backend = "healthomics" + elif tool_config.aws_batch: + backend = "aws_batch" + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Demultiplex workflow '{workflow_body.workflow_id}' has no " + f"execution backend configured (aws_batch or healthomics)." + ), + ) + + inputs = workflow_body.inputs + inputs["username"] = username + + if backend == "healthomics": + return _submit_demux_healthomics(session, tool_config, inputs) + elif backend == "aws_batch": + return _submit_demux_batch(session, tool_config, inputs, username) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown backend '{backend}'. Use 'aws_batch' or 'healthomics'.", + ) + + +def _submit_demux_batch( + session: Session, tool_config: DemuxWorkflowConfig, inputs: dict, username: str +) -> BatchJobPublic: + """Submit a demux job via AWS Batch.""" if not tool_config.aws_batch: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( - f"Demultiplex workflow '{workflow_body.workflow_id}' is not configured for " + f"Demultiplex workflow '{tool_config.workflow_id}' is not configured for " f"AWS Batch execution." ), ) - job_name = interpolate(tool_config.aws_batch.job_name, workflow_body.inputs) - command = interpolate(tool_config.aws_batch.command, workflow_body.inputs) - # Add username to inputs for interpolation - inputs = workflow_body.inputs - inputs['username'] = username + job_name = interpolate(tool_config.aws_batch.job_name, inputs) + command = interpolate(tool_config.aws_batch.command, inputs) container_overrides = { "command": command.split(), @@ -681,14 +715,64 @@ def submit_demux_job( ], } - # Submit the job to AWS Batch and create database record batch_job = submit_batch_job( session=session, job_name=job_name, container_overrides=container_overrides, job_def=tool_config.aws_batch.job_definition, job_queue=tool_config.aws_batch.job_queue, - user=username + user=username, + ) + + return BatchJobPublic.model_validate(batch_job) + + +def _submit_demux_healthomics( + session: Session, tool_config: DemuxWorkflowConfig, inputs: dict +) -> BatchJobPublic: + """Submit a demux job via AWS HealthOmics.""" + if not tool_config.healthomics: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Demultiplex workflow '{tool_config.workflow_id}' is not configured for " + f"HealthOmics execution." + ), + ) + + ho = tool_config.healthomics + + # Build HealthOmics parameters from user inputs + config defaults + # Each parameter value must be a JSON string for the HealthOmics API + omics_params = {} + if ho.parameters: + for key, tmpl in ho.parameters.items(): + if isinstance(tmpl, str) and "{{" in tmpl: + omics_params[key] = interpolate(tmpl, inputs) + else: + omics_params[key] = str(tmpl) if tmpl is not None else "" + + # Override / add any user-supplied inputs that match parameter names + for key, val in inputs.items(): + if key == "username": + continue + if key in omics_params or ho.parameters is None: + omics_params[key] = str(val) if not isinstance(val, str) else val + + run_name = None + if ho.run_name: + run_name = interpolate(ho.run_name, inputs) + + batch_job = submit_healthomics_run( + session=session, + workflow_id=ho.workflow_id, + role_arn=ho.role_arn, + output_uri=ho.output_uri, + parameters=omics_params, + run_name=run_name, + workflow_version_name=ho.workflow_version_name, + storage_type=ho.storage_type, + storage_capacity=ho.storage_capacity, ) return BatchJobPublic.model_validate(batch_job) diff --git a/core/config.py b/core/config.py index f87ff207..20878f57 100644 --- a/core/config.py +++ b/core/config.py @@ -399,6 +399,48 @@ def OAUTH_CORP_SCOPES(self) -> str | None: """Get Corporate OAuth scopes (comma-separated)""" return self._get_config_value("OAUTH_CORP_SCOPES", default="openid,email,profile") + # Bedrock / Agent Configuration + @computed_field + @property + def BEDROCK_MODEL_ID(self) -> str: + """Get Bedrock model ID for the AI chatbot agent""" + return self._get_config_value( + "BEDROCK_MODEL_ID", + default="anthropic.claude-3-5-sonnet-20241022-v2:0" + ) + + @computed_field + @property + def BEDROCK_REGION(self) -> str: + """Get AWS region for Bedrock API calls""" + return self._get_config_value( + "BEDROCK_REGION", default="us-east-1" + ) + + @computed_field + @property + def AGENTCORE_MEMORY_ID(self) -> str | None: + """Get AgentCore memory identifier for conversation persistence""" + return self._get_config_value("AGENTCORE_MEMORY_ID") + + @computed_field + @property + def CHAT_TIMEOUT_SECONDS(self) -> int: + """Get chat request timeout in seconds""" + value = self._get_config_value( + "CHAT_TIMEOUT_SECONDS", default="60" + ) + return int(value) + + @computed_field + @property + def CHAT_API_BASE_URL(self) -> str: + """Get base URL for NGS360 API used by MCP server tools""" + return self._get_config_value( + "CHAT_API_BASE_URL", + default="http://localhost:3000/api/v1" + ) + # Read environment variables from .env file, if it exists # extra='ignore' prevents validation errors from extra env vars model_config = SettingsConfigDict( diff --git a/core/deps.py b/core/deps.py index 729ae26e..8764c6bc 100644 --- a/core/deps.py +++ b/core/deps.py @@ -36,4 +36,3 @@ def get_s3_client(): SessionDep: TypeAlias = Annotated[Session, Depends(get_db)] OpenSearchDep: TypeAlias = Annotated[OpenSearch, Depends(get_opensearch_client)] -S3ClientDep: TypeAlias = Annotated[object, Depends(get_s3_client)] diff --git a/core/lifespan.py b/core/lifespan.py index 36bbc9a4..d53b310e 100644 --- a/core/lifespan.py +++ b/core/lifespan.py @@ -114,8 +114,13 @@ def _log_setting(key: str, value): # raise RuntimeError(f"Cannot start application: database initialization failed - {str(e)}") logger.info("Initializing OpenSearch indexes...") - client = get_opensearch_client() - init_indexes(client) + try: + client = get_opensearch_client() + init_indexes(client) + logger.info("OpenSearch indexes initialized successfully") + except Exception as e: + logger.warning(f"OpenSearch initialization failed (non-fatal): {e}") + logger.warning("Search functionality may be unavailable") logger.info("In lifespan...yield") try: diff --git a/main.py b/main.py index d5166a69..e175fa4b 100644 --- a/main.py +++ b/main.py @@ -27,6 +27,10 @@ from api.workflow.routes import run_router as workflow_run_router from api.pipeline.routes import router as pipeline_router from api.platforms.routes import router as platforms_router +try: + from api.chat.routes import router as chat_router +except ImportError: + chat_router = None # Customize route id's @@ -139,6 +143,8 @@ def health_check(): app.include_router(workflow_run_router, prefix=API_PREFIX) app.include_router(pipeline_router, prefix=API_PREFIX) app.include_router(platforms_router, prefix=API_PREFIX) +if chat_router is not None: + app.include_router(chat_router, prefix=API_PREFIX) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 598d7703..7acd0b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "alembic>=1.16.4", "authlib>=1.3.0", "bcrypt>=4.0.0,<5.0.0", + "bedrock-agentcore>=0.0.6", "boto3>=1.40.30", "cryptography>=45.0.6", "email-validator>=2.1.0", @@ -15,6 +16,7 @@ dependencies = [ "gunicorn>=21.0.0", "httpx>=0.27.0", "mako>=1.3.10", + "mcp>=1.26.0", "opensearch-py>=3.0.0", "pydantic>=2.11.7", "pydantic-settings>=2.10.1", @@ -26,6 +28,7 @@ dependencies = [ "sample-sheet>=0.13.0", "smart-open>=7.3.1", "sqlmodel>=0.0.24", + "strands-agents>=0.1.0", "uvicorn>=0.35.0", ] @@ -39,6 +42,7 @@ dev = [ [dependency-groups] dev = [ "flake8>=7.3.0", + "hypothesis>=6.100.0", "mock>=5.2.0", "pytest>=8.4.1", "pytest-cov>=6.2.1", diff --git a/requirements.txt b/requirements.txt index eceb3fa7..50576e43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,18 +11,31 @@ annotated-types==0.7.0 anyio==4.12.1 # via # httpx + # mcp + # sse-starlette # starlette # watchfiles +attrs==26.1.0 + # via + # jsonschema + # referencing authlib==1.6.9 # via ngs360-server (pyproject.toml) bcrypt==4.3.0 # via ngs360-server (pyproject.toml) -boto3==1.42.78 +bedrock-agentcore==1.4.7 # via ngs360-server (pyproject.toml) -botocore==1.42.68 +boto3==1.42.76 + # via + # ngs360-server (pyproject.toml) + # bedrock-agentcore + # strands-agents +botocore==1.42.77 # via + # bedrock-agentcore # boto3 # s3transfer + # strands-agents certifi==2026.2.25 # via # httpcore @@ -40,14 +53,17 @@ click==8.3.1 # sample-sheet # typer # uvicorn -cryptography==46.0.6 +cryptography==46.0.5 # via # ngs360-server (pyproject.toml) # authlib + # pyjwt # python-jose dnspython==2.8.0 # via email-validator -ecdsa==0.19.2 +docstring-parser==0.17.0 + # via strands-agents +ecdsa==0.19.1 # via python-jose email-validator==2.3.0 # via @@ -58,7 +74,7 @@ events==0.5 # via opensearch-py fastapi==0.135.1 # via ngs360-server (pyproject.toml) -fastapi-cli==0.0.24 +fastapi-cli==0.0.23 # via fastapi fastapi-cloud-cli==0.6.0 # via fastapi-cli @@ -81,18 +97,29 @@ httpx==0.28.1 # ngs360-server (pyproject.toml) # fastapi # fastapi-cloud-cli + # mcp +httpx-sse==0.4.3 + # via mcp idna==3.11 # via # anyio # email-validator # httpx # requests +importlib-metadata==8.7.1 + # via opentelemetry-api jinja2==3.1.6 # via fastapi jmespath==1.1.0 # via # boto3 # botocore +jsonschema==4.26.0 + # via + # mcp + # strands-agents +jsonschema-specifications==2025.9.1 + # via jsonschema mako==1.3.10 # via # ngs360-server (pyproject.toml) @@ -103,14 +130,37 @@ markupsafe==3.0.3 # via # jinja2 # mako +mcp==1.26.0 + # via + # ngs360-server (pyproject.toml) + # strands-agents mdurl==0.1.2 # via markdown-it-py opensearch-protobufs==0.19.0 # via opensearch-py opensearch-py==3.1.0 # via ngs360-server (pyproject.toml) +opentelemetry-api==1.40.0 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-threading + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # strands-agents +opentelemetry-instrumentation==0.61b0 + # via opentelemetry-instrumentation-threading +opentelemetry-instrumentation-threading==0.61b0 + # via strands-agents +opentelemetry-sdk==1.40.0 + # via strands-agents +opentelemetry-semantic-conventions==0.61b0 + # via + # opentelemetry-instrumentation + # opentelemetry-sdk packaging==26.0 - # via gunicorn + # via + # gunicorn + # opentelemetry-instrumentation protobuf==7.34.1 # via opensearch-protobufs pyasn1==0.6.3 @@ -122,11 +172,14 @@ pycparser==3.0 pydantic==2.12.5 # via # ngs360-server (pyproject.toml) + # bedrock-agentcore # fastapi # fastapi-cloud-cli + # mcp # pydantic-extra-types # pydantic-settings # sqlmodel + # strands-agents pydantic-core==2.41.5 # via pydantic pydantic-extra-types==2.11.0 @@ -135,30 +188,40 @@ pydantic-settings==2.12.0 # via # ngs360-server (pyproject.toml) # fastapi -pygments==2.20.0 + # mcp +pygments==2.19.2 # via rich +pyjwt==2.12.1 + # via mcp pymysql==1.1.2 # via ngs360-server (pyproject.toml) python-dateutil==2.9.0.post0 # via # botocore # opensearch-py -python-dotenv==1.2.2 +python-dotenv==1.2.1 # via # ngs360-server (pyproject.toml) # pydantic-settings # uvicorn python-jose==3.5.0 # via ngs360-server (pyproject.toml) -python-multipart==0.0.24 +python-multipart==0.0.22 # via # ngs360-server (pyproject.toml) # fastapi + # mcp pytz==2026.1.post1 # via ngs360-server (pyproject.toml) pyyaml==6.0.3 - # via uvicorn -requests==2.33.1 + # via + # strands-agents + # uvicorn +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.32.5 # via # opensearch-py # sample-sheet @@ -172,6 +235,10 @@ rich-toolkit==0.17.0 # fastapi-cloud-cli rignore==0.7.6 # via fastapi-cloud-cli +rpds-py==0.30.0 + # via + # jsonschema + # referencing rsa==4.9.1 # via python-jose s3transfer==0.16.0 @@ -188,14 +255,22 @@ six==1.17.0 # python-dateutil smart-open==7.5.0 # via ngs360-server (pyproject.toml) -sqlalchemy==2.0.49 +sqlalchemy==2.0.45 # via # alembic # sqlmodel sqlmodel==0.0.37 # via ngs360-server (pyproject.toml) -starlette==1.0.0 - # via fastapi +sse-starlette==3.3.3 + # via mcp +starlette==0.52.1 + # via + # bedrock-agentcore + # fastapi + # mcp + # sse-starlette +strands-agents==1.33.0 + # via ngs360-server (pyproject.toml) tabulate==0.9.0 # via sample-sheet terminaltables==3.1.10 @@ -207,21 +282,29 @@ typer==0.24.1 typing-extensions==4.15.0 # via # alembic + # bedrock-agentcore # fastapi # grpcio + # mcp + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pydantic # pydantic-core # pydantic-extra-types # rich-toolkit # sqlalchemy + # strands-agents # typing-inspection typing-inspection==0.4.2 # via # fastapi + # mcp # pydantic # pydantic-settings urllib3==2.6.3 # via + # bedrock-agentcore # botocore # opensearch-py # requests @@ -229,14 +312,25 @@ urllib3==2.6.3 uvicorn==0.38.0 # via # ngs360-server (pyproject.toml) + # bedrock-agentcore # fastapi # fastapi-cli # fastapi-cloud-cli + # mcp uvloop==0.22.1 # via uvicorn +watchdog==6.0.0 + # via strands-agents watchfiles==1.1.1 # via uvicorn websockets==16.0 - # via uvicorn -wrapt==2.1.1 - # via smart-open + # via + # bedrock-agentcore + # uvicorn +wrapt==1.17.3 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-threading + # smart-open +zipp==3.23.0 + # via importlib-metadata diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py new file mode 100644 index 00000000..92223d84 --- /dev/null +++ b/tests/api/test_chat.py @@ -0,0 +1,602 @@ +""" +Unit tests for the chat API routes and models. + +Tests cover: +- POST /api/v1/chat with valid message (200 + conversation_id) +- Unauthenticated request (401) +- stream=false returns JSON ChatResponse +- stream=true returns SSE text/event-stream +- Missing conversation_id creates new conversation +- Agent error returns 500 +- Bedrock service error returns 503 +- Timeout returns 504 +- Memory failure returns 200 with warning + +Requirements: 4.1–4.7, 6.3, 8.1, 8.3, 8.4 +""" + +import asyncio +import json +import uuid +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from api.chat.models import ChatRequest, ChatResponse, ChatStreamEvent + + +# --------------------------------------------------------------------------- +# Model validation tests +# --------------------------------------------------------------------------- + +class TestChatModels: + """Unit tests for Pydantic request/response models.""" + + def test_chat_request_defaults(self): + req = ChatRequest(message="hello") + assert req.message == "hello" + assert req.conversation_id is None + assert req.stream is True + + def test_chat_request_explicit_fields(self): + req = ChatRequest( + message="hi", conversation_id="conv-1", stream=False + ) + assert req.conversation_id == "conv-1" + assert req.stream is False + + def test_chat_request_rejects_extra_fields(self): + with pytest.raises(Exception): + ChatRequest(message="hi", unknown_field="bad") + + def test_chat_response_fields(self): + resp = ChatResponse(response="answer", conversation_id="c-1") + assert resp.response == "answer" + assert resp.conversation_id == "c-1" + + def test_chat_stream_event_text(self): + evt = ChatStreamEvent(event="text", data="chunk", conversation_id="c-1") + assert evt.event == "text" + + def test_chat_stream_event_error(self): + evt = ChatStreamEvent(event="error", data="oops") + assert evt.conversation_id is None + + def test_chat_stream_event_done(self): + evt = ChatStreamEvent(event="done", data="") + assert evt.event == "done" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CHAT_URL = "/api/v1/chat" +FAKE_CONV_ID = str(uuid.uuid4()) + + +def _mock_sync_response(conv_id: str | None = None): + """Return a ChatResponse as process_message would for stream=False.""" + cid = conv_id or FAKE_CONV_ID + return ChatResponse(response="Hello from agent", conversation_id=cid) + + +def _make_stream_generator(conv_id: str | None = None): + """Return an async generator as process_message would for stream=True.""" + cid = conv_id or FAKE_CONV_ID + + async def gen() -> AsyncGenerator[str, None]: + evt = ChatStreamEvent(event="text", data="Hello", conversation_id=cid) + yield f"event: text\ndata: {evt.model_dump_json()}\n\n" + done = ChatStreamEvent(event="done", data="", conversation_id=cid) + yield f"event: done\ndata: {done.model_dump_json()}\n\n" + + return gen() + + +def _make_stream_with_warning(conv_id: str | None = None): + """Stream response that includes a memory warning as first event.""" + cid = conv_id or FAKE_CONV_ID + + async def gen() -> AsyncGenerator[str, None]: + warn = ChatStreamEvent( + event="text", + data="⚠️ Conversation history could not be loaded. " + "Responding without prior context.\n\n", + conversation_id=cid, + ) + yield f"event: text\ndata: {warn.model_dump_json()}\n\n" + evt = ChatStreamEvent(event="text", data="Answer", conversation_id=cid) + yield f"event: text\ndata: {evt.model_dump_json()}\n\n" + done = ChatStreamEvent(event="done", data="", conversation_id=cid) + yield f"event: done\ndata: {done.model_dump_json()}\n\n" + + return gen() + + + +# --------------------------------------------------------------------------- +# Route tests — stream=False (JSON responses) +# --------------------------------------------------------------------------- + +class TestChatEndpointSync: + """Tests for POST /api/v1/chat with stream=false (JSON mode).""" + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_valid_message_returns_200_with_conversation_id( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.1, 4.3: valid message → 200 + conversation_id.""" + mock_pm.return_value = _mock_sync_response() + + resp = client.post( + CHAT_URL, + json={"message": "List projects", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert "response" in body + assert "conversation_id" in body + assert body["conversation_id"] == FAKE_CONV_ID + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_stream_false_returns_json( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.7: stream=false → JSON ChatResponse.""" + mock_pm.return_value = _mock_sync_response() + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/json") + body = resp.json() + assert body["response"] == "Hello from agent" + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_missing_conversation_id_creates_new( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.5: no conversation_id → response has a new one.""" + new_id = str(uuid.uuid4()) + mock_pm.return_value = ChatResponse( + response="ok", conversation_id=new_id + ) + + resp = client.post( + CHAT_URL, + json={"message": "hello", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["conversation_id"] == new_id + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_conversation_id_forwarded( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.3: existing conversation_id is forwarded to service.""" + existing_id = "conv-existing-123" + mock_pm.return_value = ChatResponse( + response="ok", conversation_id=existing_id + ) + + resp = client.post( + CHAT_URL, + json={ + "message": "follow-up", + "conversation_id": existing_id, + "stream": False, + }, + headers=auth_headers, + ) + assert resp.status_code == 200 + # Verify process_message was called with the conversation_id + call_kwargs = mock_pm.call_args.kwargs + assert call_kwargs["conversation_id"] == existing_id + + +# --------------------------------------------------------------------------- +# Route tests — stream=True (SSE responses) +# --------------------------------------------------------------------------- + +class TestChatEndpointStream: + """Tests for POST /api/v1/chat with stream=true (SSE mode).""" + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_stream_true_returns_sse_content_type( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.4, 4.7: stream=true → text/event-stream.""" + mock_pm.return_value = _make_stream_generator() + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": True}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_stream_contains_text_and_done_events( + self, mock_pm, client: TestClient, auth_headers + ): + """SSE stream includes text events followed by a done event.""" + mock_pm.return_value = _make_stream_generator() + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": True}, + headers=auth_headers, + ) + body = resp.text + assert "event: text" in body + assert "event: done" in body + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_stream_default_when_omitted( + self, mock_pm, client: TestClient, auth_headers + ): + """stream defaults to True when not provided.""" + mock_pm.return_value = _make_stream_generator() + + resp = client.post( + CHAT_URL, + json={"message": "hi"}, + headers=auth_headers, + ) + assert "text/event-stream" in resp.headers["content-type"] + + +# --------------------------------------------------------------------------- +# Authentication tests +# --------------------------------------------------------------------------- + +class TestChatAuth: + """Tests for authentication on the chat endpoint.""" + + def test_unauthenticated_returns_401( + self, unauthenticated_client: TestClient + ): + """Req 4.2, 6.3: missing JWT → 401.""" + resp = unauthenticated_client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + ) + assert resp.status_code == 401 + + def test_invalid_token_returns_401( + self, unauthenticated_client: TestClient + ): + """Req 6.3: invalid JWT → 401.""" + resp = unauthenticated_client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers={"Authorization": "Bearer invalid-token-xyz"}, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Error handling tests — sync mode +# --------------------------------------------------------------------------- + +class TestChatErrorHandlingSync: + """Error handling tests using stream=false for simpler assertions.""" + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_agent_error_returns_500( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 4.6: unhandled agent exception → 500.""" + from fastapi import HTTPException + + mock_pm.side_effect = HTTPException( + status_code=500, + detail="An error occurred while processing your message.", + ) + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 500 + assert "error" in resp.json()["detail"].lower() or "occurred" in resp.json()["detail"].lower() + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_bedrock_error_returns_503( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 8.1: Bedrock service error → 503.""" + from fastapi import HTTPException + + mock_pm.side_effect = HTTPException( + status_code=503, + detail="AI service is temporarily unavailable.", + ) + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 503 + assert "temporarily unavailable" in resp.json()["detail"] + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_timeout_returns_504( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 8.4: timeout → 504.""" + from fastapi import HTTPException + + mock_pm.side_effect = HTTPException( + status_code=504, + detail="Request timed out.", + ) + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 504 + assert "timed out" in resp.json()["detail"].lower() + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_memory_failure_returns_200_with_warning( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 8.3: memory failure → 200 with warning text.""" + warning_text = "⚠️ Conversation history could not be loaded." + mock_pm.return_value = ChatResponse( + response=f"{warning_text}\n\nHere is your answer.", + conversation_id=FAKE_CONV_ID, + ) + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert "could not be loaded" in resp.json()["response"] + + +# --------------------------------------------------------------------------- +# Error handling tests — streaming mode +# --------------------------------------------------------------------------- + +class TestChatErrorHandlingStream: + """Error handling in SSE streaming mode.""" + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_stream_memory_warning_included( + self, mock_pm, client: TestClient, auth_headers + ): + """Req 8.3: memory warning appears as first SSE event in stream.""" + mock_pm.return_value = _make_stream_with_warning() + + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": True}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert "could not be loaded" in resp.text + + +# --------------------------------------------------------------------------- +# Service-level tests (process_message) +# --------------------------------------------------------------------------- + +class TestProcessMessage: + """Tests for the process_message service function directly.""" + + @patch("api.chat.services.create_agent") + def test_sync_returns_chat_response(self, mock_create): + """process_message with stream=False returns ChatResponse.""" + conv_id = str(uuid.uuid4()) + mock_agent = MagicMock() + mock_agent.invoke_async = AsyncMock(return_value="Agent says hi") + mock_create.return_value = (mock_agent, conv_id, None) + + from api.chat.services import process_message + + result = asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="fake-jwt", + user_id="user-1", + message="hello", + conversation_id=None, + stream=False, + ) + ) + assert isinstance(result, ChatResponse) + assert result.conversation_id == conv_id + assert "Agent says hi" in result.response + + @patch("api.chat.services.create_agent") + def test_sync_timeout_raises_504(self, mock_create): + """process_message raises 504 on timeout.""" + conv_id = str(uuid.uuid4()) + + async def slow_invoke(msg): + await asyncio.sleep(999) + + mock_agent = MagicMock() + mock_agent.invoke_async = slow_invoke + mock_create.return_value = (mock_agent, conv_id, None) + + # Patch CHAT_TIMEOUT_SECONDS to a tiny value + with patch("api.chat.services.get_settings") as mock_settings: + settings = MagicMock() + settings.CHAT_TIMEOUT_SECONDS = 0.01 + settings.BEDROCK_MODEL_ID = "test" + settings.BEDROCK_REGION = "us-east-1" + settings.AGENTCORE_MEMORY_ID = None + settings.CHAT_API_BASE_URL = "http://fake" + mock_settings.return_value = settings + + from api.chat.services import process_message + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="jwt", + user_id="u1", + message="hi", + stream=False, + ) + ) + assert exc_info.value.status_code == 504 + + @patch("api.chat.services.create_agent") + def test_sync_bedrock_error_raises_503(self, mock_create): + """process_message raises 503 on Bedrock errors.""" + from botocore.exceptions import EndpointConnectionError + + conv_id = str(uuid.uuid4()) + + async def bedrock_fail(msg): + raise EndpointConnectionError(endpoint_url="https://bedrock.us-east-1") + + mock_agent = MagicMock() + mock_agent.invoke_async = bedrock_fail + mock_create.return_value = (mock_agent, conv_id, None) + + from api.chat.services import process_message + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="jwt", + user_id="u1", + message="hi", + stream=False, + ) + ) + assert exc_info.value.status_code == 503 + + @patch("api.chat.services.create_agent") + def test_sync_generic_error_raises_500(self, mock_create): + """process_message raises 500 on generic agent errors.""" + conv_id = str(uuid.uuid4()) + + async def agent_fail(msg): + raise RuntimeError("something broke") + + mock_agent = MagicMock() + mock_agent.invoke_async = agent_fail + mock_create.return_value = (mock_agent, conv_id, None) + + from api.chat.services import process_message + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="jwt", + user_id="u1", + message="hi", + stream=False, + ) + ) + assert exc_info.value.status_code == 500 + + @patch("api.chat.services.create_agent") + def test_sync_memory_warning_included_in_response(self, mock_create): + """process_message includes memory warning in response text.""" + conv_id = str(uuid.uuid4()) + mock_agent = MagicMock() + mock_agent.invoke_async = AsyncMock(return_value="Answer") + mock_create.return_value = ( + mock_agent, + conv_id, + "Conversation history could not be loaded.", + ) + + from api.chat.services import process_message + + result = asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="jwt", + user_id="u1", + message="hi", + stream=False, + ) + ) + assert isinstance(result, ChatResponse) + assert "could not be loaded" in result.response + assert "Answer" in result.response + + @patch("api.chat.services.create_agent") + def test_create_agent_bedrock_error_raises_503(self, mock_create): + """If create_agent itself fails with a Bedrock error → 503.""" + from botocore.exceptions import EndpointConnectionError + + mock_create.side_effect = EndpointConnectionError( + endpoint_url="https://bedrock.us-east-1" + ) + + from api.chat.services import process_message + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + asyncio.get_event_loop().run_until_complete( + process_message( + user_jwt="jwt", + user_id="u1", + message="hi", + stream=False, + ) + ) + assert exc_info.value.status_code == 503 + + +# --------------------------------------------------------------------------- +# Validation tests +# --------------------------------------------------------------------------- + +class TestChatValidation: + """Request validation edge cases.""" + + def test_empty_body_returns_422( + self, client: TestClient, auth_headers + ): + """Missing body → 422.""" + resp = client.post(CHAT_URL, headers=auth_headers) + assert resp.status_code == 422 + + def test_missing_message_returns_422( + self, client: TestClient, auth_headers + ): + """Body without message field → 422.""" + resp = client.post( + CHAT_URL, + json={"stream": False}, + headers=auth_headers, + ) + assert resp.status_code == 422 + + @patch("api.chat.routes.process_message", new_callable=AsyncMock) + def test_extra_fields_rejected( + self, mock_pm, client: TestClient, auth_headers + ): + """ChatRequest(extra='forbid') rejects unknown fields.""" + resp = client.post( + CHAT_URL, + json={"message": "hi", "stream": False, "rogue": "field"}, + headers=auth_headers, + ) + assert resp.status_code == 422 diff --git a/tests/api/test_jobs.py b/tests/api/test_jobs.py index 0c93ce66..90f4ff82 100644 --- a/tests/api/test_jobs.py +++ b/tests/api/test_jobs.py @@ -11,6 +11,7 @@ from api.jobs.models import ( BatchJob, + BatchJobCreate, BatchJobUpdate, BatchJobSubmit, JobStatus, @@ -41,7 +42,6 @@ def test_job_status_enum(self): def test_batch_job_model(self): """Test BatchJob model creation""" job = BatchJob( - id=str('21de8760-87e0-45cd-946a-ae96ab789f2a'), name="test-job", command="echo hello", user="testuser", @@ -54,16 +54,30 @@ def test_batch_job_model(self): assert job.viewed is False assert job.id is not None + def test_batch_job_create_schema(self): + """Test BatchJobCreate schema""" + job_create = BatchJobCreate( + name="test-job", + command="echo hello", + user="testuser", + aws_job_id="aws-123", + status=JobStatus.SUBMITTED, + ) + assert job_create.name == "test-job" + assert job_create.command == "echo hello" + assert job_create.user == "testuser" + assert job_create.aws_job_id == "aws-123" + assert job_create.status == JobStatus.SUBMITTED + def test_batch_job_update_schema(self): """Test BatchJobUpdate schema with partial updates""" job_update = BatchJobUpdate( status=JobStatus.RUNNING, log_stream_name="test-stream", - viewed=False ) assert job_update.status == JobStatus.RUNNING assert job_update.log_stream_name == "test-stream" - assert job_update.viewed is False + assert job_update.name is None def test_aws_batch_environment_model(self): """Test AwsBatchEnvironment model""" @@ -182,6 +196,7 @@ def test_submit_job(self, mock_boto_client, client: TestClient): data = response.json() assert data["name"] == "test-job" assert data["user"] == "testuser" + assert data["aws_job_id"] == "aws-job-123" assert data["status"] == JobStatus.SUBMITTED assert "id" in data @@ -213,7 +228,7 @@ def test_submit_job_with_environment(self, mock_boto_client, client: TestClient) assert response.status_code == 201 data = response.json() - assert data["id"] == "aws-job-456" + assert data["aws_job_id"] == "aws-job-456" # Verify container overrides include environment call_args = mock_batch.submit_job.call_args[1] @@ -233,19 +248,27 @@ def test_get_jobs_empty(self, client: TestClient): assert data["count"] == 0 assert data["data"] == [] - def test_get_jobs_with_data(self, client: TestClient, session: Session): + @patch("api.jobs.services.boto3.client") + def test_get_jobs_with_data(self, mock_boto_client, client: TestClient, session: Session): """Test getting list of jobs""" - # Add jobs to the database to ensure they are returned by the GET endpoint + # Mock AWS Batch + mock_batch = MagicMock() + mock_batch.submit_job.return_value = { + "jobId": "aws-job-123", + "jobName": "test-job", + } + mock_boto_client.return_value = mock_batch + + # Create jobs via API for i in range(3): - job = BatchJob( - id=f"aws-job-{i}", - name=f"test-job-{i}", - command=f"echo hello-{i}", - user="testuser", - status=JobStatus.SUBMITTED, - ) - session.add(job) - session.commit() + job_data = { + "job_name": f"test-job-{i}", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": f"echo hello-{i}", + "user": "testuser", + } + client.post("/api/v1/jobs", json=job_data) # Get jobs response = client.get("/api/v1/jobs") @@ -254,27 +277,31 @@ def test_get_jobs_with_data(self, client: TestClient, session: Session): assert data["count"] == 3 assert len(data["data"]) == 3 - def test_get_jobs_with_filters(self, client: TestClient, session: Session): + @patch("api.jobs.services.boto3.client") + def test_get_jobs_with_filters(self, mock_boto_client, client: TestClient): """Test filtering jobs by user and status""" - # Create jobs for different users - job = BatchJob( - id="job-1", - name="user1-job", - command="echo hello", - user="user1", - status=JobStatus.SUBMITTED, - ) - session.add(job) + mock_batch = MagicMock() + mock_batch.submit_job.return_value = { + "jobId": "aws-job-123", + "jobName": "test-job", + } + mock_boto_client.return_value = mock_batch - job = BatchJob( - id="job-2", - name="user2-job", - command="echo hello", - user="user2", - status=JobStatus.SUBMITTED, - ) - session.add(job) - session.commit() + # Create jobs for different users + client.post("/api/v1/jobs", json={ + "job_name": "user1-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "user1", + }) + client.post("/api/v1/jobs", json={ + "job_name": "user2-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "user2", + }) # Filter by user response = client.get("/api/v1/jobs?user=user1") @@ -316,19 +343,25 @@ def test_get_job_not_found(self, client: TestClient): response = client.get(f"/api/v1/jobs/{fake_id}") assert response.status_code == 404 - def test_update_job(self, client: TestClient, session: Session): + @patch("api.jobs.services.boto3.client") + def test_update_job(self, mock_boto_client, client: TestClient): """Test updating a job""" + mock_batch = MagicMock() + mock_batch.submit_job.return_value = { + "jobId": "aws-job-123", + "jobName": "test-job", + } + mock_boto_client.return_value = mock_batch # Create job - job = BatchJob( - id="1", - name="user-job", - command="echo hello", - user="auser", - status=JobStatus.SUBMITTED, - ) - session.add(job) - session.commit() + response = client.post("/api/v1/jobs", json={ + "job_name": "test-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "testuser", + }) + job_id = response.json()["id"] # Update job update_data = { @@ -336,7 +369,7 @@ def test_update_job(self, client: TestClient, session: Session): "log_stream_name": "test-stream", "viewed": True, } - response = client.put("/api/v1/jobs/1", json=update_data) + response = client.put(f"/api/v1/jobs/{job_id}", json=update_data) assert response.status_code == 200 data = response.json() assert data["status"] == JobStatus.RUNNING @@ -373,13 +406,13 @@ def test_update_job_partial(self, mock_boto_client, client: TestClient): assert data["user"] == original_user # Unchanged def test_lambda_update_job_log(self, session: Session, client: TestClient): - """Test updating job log stream name.""" + """Test updating job log stream name. This is what the Lambda function does.""" # Set up test parameters # Set up supporting mocks job = BatchJob( name="test-job", command="echo hello", user="testuser", - id="aws-job-123", status=JobStatus.SUBMITTED + aws_job_id="aws-job-123", status=JobStatus.SUBMITTED ) session.add(job) session.commit() @@ -387,11 +420,12 @@ def test_lambda_update_job_log(self, session: Session, client: TestClient): # Test update_data = { + "aws_job_id": "aws-job-123", "log_stream_name": "new-log-stream", "status": "RUNNING" } # Check results - response = client.put(f"/api/v1/jobs/{job.id}", json=update_data) + response = client.put("/api/v1/jobs", json=update_data) assert response.status_code == 200 data = response.json() @@ -399,20 +433,25 @@ def test_lambda_update_job_log(self, session: Session, client: TestClient): assert data["log_stream_name"] == "new-log-stream" assert data["status"] == JobStatus.RUNNING - def test_jobs_pagination(self, client: TestClient, session: Session): + @patch("api.jobs.services.boto3.client") + def test_jobs_pagination(self, mock_boto_client, client: TestClient): """Test job list pagination""" + mock_batch = MagicMock() + mock_batch.submit_job.return_value = { + "jobId": "aws-job-123", + "jobName": "test-job", + } + mock_boto_client.return_value = mock_batch - # Create 25 mock jobs + # Create 25 jobs for i in range(25): - job = BatchJob( - id=f"job-{i}", - name=f"test-job-{i}", - command=f"echo hello-{i}", - user="testuser", - status=JobStatus.SUBMITTED, - ) - session.add(job) - session.commit() + client.post("/api/v1/jobs", json={ + "job_name": f"test-job-{i}", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": f"echo hello-{i}", + "user": "testuser", + }) # Get first page (default limit 100) response = client.get("/api/v1/jobs") @@ -427,33 +466,41 @@ def test_jobs_pagination(self, client: TestClient, session: Session): assert len(data["data"]) == 10 assert data["count"] == 25 - def test_jobs_sorting(self, client: TestClient, session: Session): + @patch("api.jobs.services.boto3.client") + def test_jobs_sorting(self, mock_boto_client, client: TestClient): """Test job list sorting by different fields""" - # Create jobs with different names - job = BatchJob( - id="job-1", - name="zebra-job", - command="echo hello", - user="testuser", - ) - session.add(job) - - job = BatchJob( - id="job-2", - name="alpha-job", - command="echo hello", - user="testuser", - ) - session.add(job) + import time + mock_batch = MagicMock() + mock_batch.submit_job.return_value = { + "jobId": "aws-job-123", + "jobName": "test-job", + } + mock_boto_client.return_value = mock_batch - job = BatchJob( - id="job-3", - name="beta-job", - command="echo hello", - user="testuser", - ) - session.add(job) - session.commit() + # Create jobs with different names + client.post("/api/v1/jobs", json={ + "job_name": "zebra-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "testuser", + }) + time.sleep(0.01) # Small delay to ensure different timestamps + client.post("/api/v1/jobs", json={ + "job_name": "alpha-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "testuser", + }) + time.sleep(0.01) + client.post("/api/v1/jobs", json={ + "job_name": "beta-job", + "job_definition": "test-def:1", + "job_queue": "test-queue", + "command": "echo hello", + "user": "testuser", + }) # Test default sort (submitted_on desc - most recent first) response = client.get("/api/v1/jobs") @@ -495,68 +542,62 @@ class TestJobsServices: def test_create_batch_job(self, session: Session): """Test creating a batch job in database""" + from api.jobs.services import create_batch_job - job = BatchJob( - id=str(uuid.uuid4()), + job_create = BatchJobCreate( name="test-job", command="echo hello", user="testuser", aws_job_id="aws-123", status=JobStatus.SUBMITTED, ) - session.add(job) - session.commit() - session.refresh(job) + job = create_batch_job(session, job_create) assert job.id is not None assert job.name == "test-job" + assert job.aws_job_id == "aws-123" assert job.status == JobStatus.SUBMITTED def test_get_batch_job(self, session: Session): """Test retrieving a batch job by ID""" - from api.jobs.services import get_batch_job + from api.jobs.services import create_batch_job, get_batch_job # Create job - job = BatchJob( - id=str(uuid.uuid4()), + job_create = BatchJobCreate( name="test-job", command="echo hello", user="testuser", - aws_job_id="aws-123", - status=JobStatus.SUBMITTED, ) - session.add(job) - session.commit() - session.refresh(job) + created_job = create_batch_job(session, job_create) # Retrieve job - retrieved_job = get_batch_job(session, job.id) - assert retrieved_job.id == job.id + retrieved_job = get_batch_job(session, created_job.id) + assert retrieved_job.id == created_job.id assert retrieved_job.name == "test-job" def test_get_batch_job_not_found(self, session: Session): - """Test retrieving non-existent job returns None""" + """Test retrieving non-existent job raises HTTPException""" from api.jobs.services import get_batch_job + from fastapi import HTTPException - fake_id = str(uuid.uuid4()) - retrieved_job = get_batch_job(session, fake_id) - assert retrieved_job is None + fake_id = uuid.uuid4() + with pytest.raises(HTTPException) as exc_info: + get_batch_job(session, fake_id) + assert exc_info.value.status_code == 404 def test_get_batch_jobs_with_filters(self, session: Session): """Test getting jobs with filters""" - from api.jobs.services import get_batch_jobs + from api.jobs.services import create_batch_job, get_batch_jobs # Create multiple jobs for i in range(5): - job = BatchJob( - id=str(uuid.uuid4()), + job_create = BatchJobCreate( name=f"job-{i}", command=f"echo {i}", user="user1" if i < 3 else "user2", status=JobStatus.SUBMITTED if i < 2 else JobStatus.RUNNING, ) - session.add(job) - session.commit() + create_batch_job(session, job_create) # Filter by user jobs, count = get_batch_jobs(session, user="user1") @@ -574,26 +615,23 @@ def test_get_batch_jobs_with_filters(self, session: Session): def test_update_batch_job(self, session: Session): """Test updating a batch job""" - from api.jobs.services import update_batch_job + from api.jobs.services import create_batch_job, update_batch_job # Create job - job = BatchJob( - id=str(uuid.uuid4()), + job_create = BatchJobCreate( name="test-job", command="echo hello", user="testuser", status=JobStatus.SUBMITTED, ) - session.add(job) - session.commit() - session.refresh(job) + job = create_batch_job(session, job_create) # Update job job_update = BatchJobUpdate( status=JobStatus.RUNNING, log_stream_name="test-stream", ) - updated_job = update_batch_job(session, job, job_update) + updated_job = update_batch_job(session, job.id, job_update) assert updated_job.status == JobStatus.RUNNING assert updated_job.log_stream_name == "test-stream" @@ -628,6 +666,7 @@ def test_submit_batch_job(self, mock_boto_client, session: Session): assert job.id is not None assert job.name == "test-job" + assert job.aws_job_id == "aws-job-123" assert job.status == JobStatus.SUBMITTED assert job.user == "testuser" assert "echo hello" in job.command diff --git a/tests/api/test_mcp_server.py b/tests/api/test_mcp_server.py new file mode 100644 index 00000000..a26a92cd --- /dev/null +++ b/tests/api/test_mcp_server.py @@ -0,0 +1,560 @@ +""" +Property-based tests for the MCP server. + +Uses hypothesis to verify correctness properties across randomized inputs. +""" + +from unittest.mock import patch, MagicMock +import httpx +import hypothesis +from hypothesis import given, settings, strategies as st +import pytest + +from api.chat.mcp_server import create_mcp_server, _make_api_caller + + +# Strategy: printable JWT-like strings (non-empty, no whitespace) +jwt_strategy = st.text( + alphabet=st.characters(whitelist_categories=("L", "N", "P")), + min_size=1, + max_size=200, +) + + +class TestJWTPassthrough: + """ + Feature: ngs360-ai-chatbot, Property 1: JWT passthrough to MCP tools + + For any MCP tool invocation and any valid JWT string, the outgoing HTTP + request to the NGS360 REST API SHALL include an Authorization: Bearer + header matching the JWT provided at MCP server initialization. + + Validates: Requirements 1.2, 1.5, 6.1, 6.2 + """ + + @given(jwt=jwt_strategy) + @settings(max_examples=100) + def test_api_caller_passes_jwt_in_header(self, jwt: str): + """The low-level API caller includes the exact JWT in every request.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_response) as mock_req: + caller = _make_api_caller(jwt, "http://fake-api") + caller("GET", "/projects") + + mock_req.assert_called_once() + call_kwargs = mock_req.call_args + headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers") + assert headers["Authorization"] == f"Bearer {jwt}" + + @given(jwt=jwt_strategy) + @settings(max_examples=100) + def test_mcp_tool_list_projects_passes_jwt(self, jwt: str): + """Calling the list_projects MCP tool forwards the JWT to httpx.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_response.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_response) as mock_req: + mcp = create_mcp_server(jwt, "http://fake-api") + # Get the tool function from the registry and call it directly + tools = {t.name: t for t in mcp._tool_manager.list_tools()} + list_projects_tool = tools["list_projects"] + list_projects_tool.fn() + + mock_req.assert_called_once() + call_kwargs = mock_req.call_args + headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers") + assert headers["Authorization"] == f"Bearer {jwt}" + + @given(jwt=jwt_strategy) + @settings(max_examples=100) + def test_mcp_tool_get_project_passes_jwt(self, jwt: str): + """Calling get_project MCP tool forwards the JWT to httpx.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "P-1"} + mock_response.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_response) as mock_req: + mcp = create_mcp_server(jwt, "http://fake-api") + tools = {t.name: t for t in mcp._tool_manager.list_tools()} + tools["get_project"].fn(project_id="P-1") + + mock_req.assert_called_once() + call_kwargs = mock_req.call_args + headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers") + assert headers["Authorization"] == f"Bearer {jwt}" + + @given(jwt=jwt_strategy) + @settings(max_examples=100) + def test_mcp_post_tool_passes_jwt(self, jwt: str): + """Mutation tools (POST) also forward the JWT.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"job_id": "J-1"} + mock_response.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_response) as mock_req: + mcp = create_mcp_server(jwt, "http://fake-api") + tools = {t.name: t for t in mcp._tool_manager.list_tools()} + tools["submit_demux_workflow"].fn( + workflow_id="wf-1", run_barcode="RUN001" + ) + + mock_req.assert_called_once() + call_kwargs = mock_req.call_args + headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers") + assert headers["Authorization"] == f"Bearer {jwt}" + + +# --------------------------------------------------------------------------- +# Unit tests for MCP tools +# Requirements: 1.1–1.5 +# --------------------------------------------------------------------------- + + +class TestMCPToolSuccessResponses: + """ + Verify each tool returns the expected data shape on success. + Validates: Requirements 1.1, 1.3 + """ + + def _get_tools(self, jwt="test-jwt", base_url="http://fake-api"): + mcp = create_mcp_server(jwt, base_url) + return {t.name: t for t in mcp._tool_manager.list_tools()} + + def _mock_json_response(self, data, status_code=200): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = data + resp.raise_for_status = MagicMock() + return resp + + # -- Project tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_projects_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([{"id": "P-1"}]) + tools = self._get_tools() + result = tools["list_projects"].fn() + assert isinstance(result, list) + assert result[0]["id"] == "P-1" + + @patch("api.chat.mcp_server.httpx.request") + def test_get_project_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"project_id": "P-1", "name": "Test"} + ) + tools = self._get_tools() + result = tools["get_project"].fn(project_id="P-1") + assert result["project_id"] == "P-1" + + @patch("api.chat.mcp_server.httpx.request") + def test_search_projects_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["search_projects"].fn(query="test") + assert isinstance(result, list) + + # -- Run tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_runs_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response( + [{"barcode": "RUN001"}] + ) + tools = self._get_tools() + result = tools["list_runs"].fn() + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_get_run_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"barcode": "RUN001"} + ) + tools = self._get_tools() + result = tools["get_run"].fn(run_barcode="RUN001") + assert result["barcode"] == "RUN001" + + @patch("api.chat.mcp_server.httpx.request") + def test_get_run_samplesheet_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response({"rows": []}) + tools = self._get_tools() + result = tools["get_run_samplesheet"].fn(run_barcode="RUN001") + assert "rows" in result + + @patch("api.chat.mcp_server.httpx.request") + def test_get_run_metrics_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response({"metrics": []}) + tools = self._get_tools() + result = tools["get_run_metrics"].fn(run_barcode="RUN001") + assert "metrics" in result + + # -- Sample tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_get_project_samples_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response( + [{"sample_id": "S-1"}] + ) + tools = self._get_tools() + result = tools["get_project_samples"].fn(project_id="P-1") + assert isinstance(result, list) + + # -- File tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_files_by_entity_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["list_files_by_entity"].fn( + entity_type="PROJECT", entity_id="P-1" + ) + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_browse_s3_files_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"files": [], "folders": []} + ) + tools = self._get_tools() + result = tools["browse_s3_files"].fn(uri="s3://bucket/prefix") + assert "files" in result + + @patch("api.chat.mcp_server.httpx.request") + def test_download_file_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"url": "https://signed-url"} + ) + tools = self._get_tools() + result = tools["download_file"].fn(path="s3://bucket/file.txt") + assert "url" in result + + # -- Job tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_jobs_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["list_jobs"].fn() + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_get_job_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"job_id": "J-1", "status": "SUCCEEDED"} + ) + tools = self._get_tools() + result = tools["get_job"].fn(job_id="J-1") + assert result["job_id"] == "J-1" + + @patch("api.chat.mcp_server.httpx.request") + def test_get_job_log_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response({"log": "output"}) + tools = self._get_tools() + result = tools["get_job_log"].fn(job_id="J-1") + assert "log" in result + + # -- QC tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_search_qc_records_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["search_qc_records"].fn() + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_get_qc_record_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"qcrecord_id": "QC-1"} + ) + tools = self._get_tools() + result = tools["get_qc_record"].fn(qcrecord_id="QC-1") + assert result["qcrecord_id"] == "QC-1" + + # -- Workflow tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_workflows_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["list_workflows"].fn() + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_get_workflow_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"workflow_id": "WF-1"} + ) + tools = self._get_tools() + result = tools["get_workflow"].fn(workflow_id="WF-1") + assert result["workflow_id"] == "WF-1" + + @patch("api.chat.mcp_server.httpx.request") + def test_list_workflow_runs_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["list_workflow_runs"].fn(workflow_id="WF-1") + assert isinstance(result, list) + + # -- Pipeline tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_list_pipelines_returns_list(self, mock_req): + mock_req.return_value = self._mock_json_response([]) + tools = self._get_tools() + result = tools["list_pipelines"].fn() + assert isinstance(result, list) + + @patch("api.chat.mcp_server.httpx.request") + def test_get_pipeline_returns_dict(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"pipeline_id": "PL-1"} + ) + tools = self._get_tools() + result = tools["get_pipeline"].fn(pipeline_id="PL-1") + assert result["pipeline_id"] == "PL-1" + + # -- Search tools -- + + @patch("api.chat.mcp_server.httpx.request") + def test_cross_entity_search_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"results": []} + ) + tools = self._get_tools() + result = tools["cross_entity_search"].fn(query="test") + assert "results" in result + + # -- Action tools (mutations) -- + + @patch("api.chat.mcp_server.httpx.request") + def test_submit_demux_workflow_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"job_id": "J-new"} + ) + tools = self._get_tools() + result = tools["submit_demux_workflow"].fn( + workflow_id="wf-1", run_barcode="RUN001" + ) + assert result["job_id"] == "J-new" + + @patch("api.chat.mcp_server.httpx.request") + def test_submit_pipeline_job_returns_data(self, mock_req): + mock_req.return_value = self._mock_json_response( + {"job_id": "J-pipe"} + ) + tools = self._get_tools() + result = tools["submit_pipeline_job"].fn( + project_id="P-1", + action="run", + platform="illumina", + project_type="wgs", + ) + assert result["job_id"] == "J-pipe" + + +class TestMCPToolErrorHandling: + """ + Verify MCP tools return structured error dicts on API errors. + Validates: Requirements 1.4 + """ + + def _get_tools(self): + mcp = create_mcp_server("test-jwt", "http://fake-api") + return {t.name: t for t in mcp._tool_manager.list_tools()} + + def _mock_http_error(self, status_code, detail="Not found"): + """Create a mock that raises HTTPStatusError.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.text = detail + resp.json.return_value = {"detail": detail} + + request = MagicMock(spec=httpx.Request) + error = httpx.HTTPStatusError( + message=f"{status_code}", request=request, response=resp + ) + return error + + @patch("api.chat.mcp_server.httpx.request") + def test_404_returns_error_dict(self, mock_req): + """API 404 → structured error dict, no exception raised.""" + mock_req.side_effect = self._mock_http_error(404, "Project not found") + # Need to make raise_for_status raise the error + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = self._mock_http_error( + 404, "Project not found" + ) + mock_req.side_effect = None + mock_req.return_value = mock_resp + + tools = self._get_tools() + result = tools["get_project"].fn(project_id="P-9999") + assert result["error"] == 404 + assert "message" in result + + @patch("api.chat.mcp_server.httpx.request") + def test_500_returns_error_dict(self, mock_req): + """API 500 → structured error dict.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_resp.json.return_value = {"detail": "Internal Server Error"} + + request = MagicMock(spec=httpx.Request) + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + message="500", request=request, response=mock_resp + ) + mock_req.return_value = mock_resp + + tools = self._get_tools() + result = tools["list_projects"].fn() + assert result["error"] == 500 + assert "message" in result + + @patch("api.chat.mcp_server.httpx.request") + def test_403_returns_error_dict(self, mock_req): + """API 403 → structured error dict.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + mock_resp.json.return_value = {"detail": "Forbidden"} + + request = MagicMock(spec=httpx.Request) + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + message="403", request=request, response=mock_resp + ) + mock_req.return_value = mock_resp + + tools = self._get_tools() + result = tools["get_run"].fn(run_barcode="RUN001") + assert result["error"] == 403 + + +class TestMCPToolNetworkErrors: + """ + Verify MCP tools handle network errors gracefully. + Validates: Requirements 1.4 + """ + + def _get_tools(self): + mcp = create_mcp_server("test-jwt", "http://fake-api") + return {t.name: t for t in mcp._tool_manager.list_tools()} + + @patch("api.chat.mcp_server.httpx.request") + def test_connect_error_returns_503(self, mock_req): + """Network connection failure → error dict with 503.""" + mock_req.side_effect = httpx.ConnectError("Connection refused") + tools = self._get_tools() + result = tools["list_projects"].fn() + assert result["error"] == 503 + assert "Could not reach" in result["message"] + + @patch("api.chat.mcp_server.httpx.request") + def test_request_error_returns_503(self, mock_req): + """Generic request error → error dict with 503.""" + mock_req.side_effect = httpx.RequestError( + "timeout", request=MagicMock(spec=httpx.Request) + ) + tools = self._get_tools() + result = tools["get_project"].fn(project_id="P-1") + assert result["error"] == 503 + assert "Could not reach" in result["message"] + + @patch("api.chat.mcp_server.httpx.request") + def test_network_error_on_post_tool(self, mock_req): + """Network error on mutation tool → same graceful handling.""" + mock_req.side_effect = httpx.ConnectError("Connection refused") + tools = self._get_tools() + result = tools["submit_demux_workflow"].fn( + workflow_id="wf-1", run_barcode="RUN001" + ) + assert result["error"] == 503 + + +class TestMCPServerToolRegistry: + """Verify the MCP server exposes the expected set of tools.""" + + def test_all_expected_tools_registered(self): + """All ~25 tools are present in the registry.""" + mcp = create_mcp_server("jwt", "http://fake") + tool_names = {t.name for t in mcp._tool_manager.list_tools()} + + expected = { + "list_projects", "get_project", "search_projects", + "list_runs", "get_run", "search_runs", + "get_run_samplesheet", "get_run_metrics", + "get_project_samples", + "list_files_by_entity", "browse_s3_files", "download_file", + "list_jobs", "get_job", "get_job_log", + "search_qc_records", "get_qc_record", + "list_workflows", "get_workflow", "list_workflow_runs", + "list_pipelines", "get_pipeline", + "cross_entity_search", + "submit_demux_workflow", "submit_pipeline_job", + } + assert expected.issubset(tool_names), ( + f"Missing tools: {expected - tool_names}" + ) + + def test_tool_count_at_least_25(self): + """At least 25 tools are registered.""" + mcp = create_mcp_server("jwt", "http://fake") + tools = list(mcp._tool_manager.list_tools()) + assert len(tools) >= 25 + + +class TestAPICallerUnit: + """Unit tests for the _make_api_caller helper.""" + + def test_base_url_trailing_slash_stripped(self): + """Base URL trailing slash is normalized.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_resp) as mock_req: + caller = _make_api_caller("jwt", "http://api.example.com/api/v1/") + caller("GET", "/projects") + + call_args = mock_req.call_args + url = call_args.args[1] if len(call_args.args) > 1 else call_args[0][1] + assert "//" not in url.replace("http://", "") + + def test_params_forwarded(self): + """Query params are passed through to httpx.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = [] + mock_resp.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_resp) as mock_req: + caller = _make_api_caller("jwt", "http://api") + caller("GET", "/projects", params={"page": 2, "per_page": 10}) + + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["params"] == {"page": 2, "per_page": 10} + + def test_json_body_forwarded(self): + """JSON body is passed through for POST requests.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = {"ok": True} + mock_resp.raise_for_status = MagicMock() + + with patch("api.chat.mcp_server.httpx.request", return_value=mock_resp) as mock_req: + caller = _make_api_caller("jwt", "http://api") + body = {"action": "run", "platform": "illumina"} + caller("POST", "/actions/submit", json_body=body) + + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["json"] == body diff --git a/tests/api/test_projects.py b/tests/api/test_projects.py index 02467c89..c2e18fe7 100644 --- a/tests/api/test_projects.py +++ b/tests/api/test_projects.py @@ -579,6 +579,7 @@ def test_submit_create_project_job( # Verify response structure assert "id" in response_json + assert response_json["aws_job_id"] == "aws-batch-job-123" assert response_json["status"] == "SUBMITTED" assert response_json["user"] == "testuser" @@ -662,6 +663,7 @@ def test_submit_export_results_job( assert response.status_code == 201 response_json = response.json() + assert response_json["aws_job_id"] == "aws-batch-job-456" assert response_json["status"] == "SUBMITTED" # Verify AWS Batch was called @@ -794,6 +796,7 @@ def test_submit_create_project_with_auto_release_ignored( # Should succeed and ignore auto_release assert response.status_code == 201 response_json = response.json() + assert response_json["aws_job_id"] == "aws-batch-job-789" assert response_json["status"] == "SUBMITTED" @@ -1186,4 +1189,4 @@ def test_ingest_vendor_data( # Check results assert response.status_code == 201 response_json = response.json() - assert response_json["id"] == "aws-batch-job-123" + assert response_json["aws_job_id"] == "aws-batch-job-123" diff --git a/tests/api/test_runs.py b/tests/api/test_runs.py index 4e067b0d..58a28282 100644 --- a/tests/api/test_runs.py +++ b/tests/api/test_runs.py @@ -1088,12 +1088,12 @@ def mock_boto3_client(service_name, region_name=None): assert response.status_code == 200 data = response.json() - assert "id" in data - assert "status" in data - assert "command" in data - assert data["id"] == "test-job-123" + assert data["aws_job_id"] == "test-job-123" assert data["name"] == "cellranger-mkfastq-test-run" + assert "command" in data assert data["command"] == "mkfastq.sh" + assert "id" in data + assert "status" in data assert data["user"] == test_user.username def test_submit_job_with_jinja_expressions( @@ -1178,7 +1178,7 @@ def mock_boto3_client(service_name, region_name=None): # Verify Jinja2 expression was evaluated correctly assert data["name"] == "test-file.txt-5000" assert data["command"] == "run.sh 5000" - assert data["id"] == "job-456" + assert data["aws_job_id"] == "job-456" assert data["user"] == test_user.username # Verify container overrides @@ -1266,7 +1266,7 @@ def test_submit_job_no_aws_batch_config( assert response.status_code == 400 data = response.json() - assert "not configured for AWS Batch" in data["detail"] + assert "no execution backend configured" in data["detail"] def test_submit_job_batch_client_error( self, client: TestClient, mock_s3_client, monkeypatch, test_user @@ -1410,7 +1410,7 @@ def mock_boto3_client(service_name, region_name=None): assert response.status_code == 200 data = response.json() - assert data["id"] == "job-789" + assert data["aws_job_id"] == "job-789" assert data["name"] == "no-env-job" assert data["user"] == test_user.username @@ -1539,7 +1539,7 @@ def mock_boto3_client(service_name, region_name=None): assert response.status_code == 200 data = response.json() assert data["name"] == "complex-test_string-42" - assert data["id"] == "job-complex" + assert data["aws_job_id"] == "job-complex" assert data["user"] == test_user.username # Verify environment variables have correct values diff --git a/uv.lock b/uv.lock index dc87e8d3..3b219491 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "authlib" version = "1.6.9" @@ -109,32 +118,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "bedrock-agentcore" +version = "1.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/5c/2ad1747ff2bc4b6bb8828fdc8e769f6c34daa0c4ca4d853cff603ea04aeb/bedrock_agentcore-1.4.7.tar.gz", hash = "sha256:422805482e47593010128a86495dff644507624b00c6e09950613c7241ae5375", size = 483923, upload-time = "2026-03-18T22:46:37.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/e7/df6cbe814292353460c0988504eee4e90cc08dde03bf5e1da85176b5f0b4/bedrock_agentcore-1.4.7-py3-none-any.whl", hash = "sha256:7515ddf779a4f32fd4a5c8dcf29c9399babe0ea14ea9004d2c69bcad40754622", size = 148250, upload-time = "2026-03-18T22:46:36.662Z" }, +] + [[package]] name = "boto3" -version = "1.40.30" +version = "1.42.76" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/a7/3fde131d2431d1801e3f16f1b428cf9b8c6677996716c5286a72eb43ecb7/boto3-1.40.30.tar.gz", hash = "sha256:e95db539c938710917f4cb4fc5915f71b27f2c836d949a1a95df7895d2e9ec8b", size = 111636, upload-time = "2025-09-12T19:23:22.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/43/f1865e3e2aa91c1aa54db90a82ed17b8c0dc60c354045adf1c2134e5cbd8/boto3-1.40.30-py3-none-any.whl", hash = "sha256:04e89abf61240857bf7dec160e22f097eec68c502509b2bb3c5010a22cb91052", size = 139343, upload-time = "2025-09-12T19:23:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" }, ] [[package]] name = "botocore" -version = "1.40.30" +version = "1.42.76" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/be/086ff6f031c407540e8226b3a4921dd18a05688224324c2df60457f9bcc0/botocore-1.40.30.tar.gz", hash = "sha256:8a74f77cfe5c519826d22f7613f89544cbb8491a1a49d965031bd997f89a8e3f", size = 14349135, upload-time = "2025-09-12T19:23:12.57Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/a8/3644f482b7b319f3fda87d4583f7b073c0cdf4a6d1b58e5a92555fe3e2e3/botocore-1.40.30-py3-none-any.whl", hash = "sha256:1d87874ad81234bec3e83f9de13618f67ccdfefd08d6b8babc041cd45007447e", size = 14022003, upload-time = "2025-09-12T19:23:09.163Z" }, + { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" }, ] [[package]] @@ -289,55 +316,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] @@ -349,6 +376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[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" @@ -471,6 +507,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, @@ -479,6 +516,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, @@ -548,6 +586,27 @@ 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 = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -557,6 +616,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -587,6 +658,33 @@ 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.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[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 = "mako" version = "1.3.10" @@ -648,6 +746,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -674,6 +797,7 @@ dependencies = [ { name = "alembic" }, { name = "authlib" }, { name = "bcrypt" }, + { name = "bedrock-agentcore" }, { name = "boto3" }, { name = "cryptography" }, { name = "email-validator" }, @@ -681,6 +805,7 @@ dependencies = [ { name = "gunicorn" }, { name = "httpx" }, { name = "mako" }, + { name = "mcp" }, { name = "opensearch-py" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -692,6 +817,7 @@ dependencies = [ { name = "sample-sheet" }, { name = "smart-open" }, { name = "sqlmodel" }, + { name = "strands-agents" }, { name = "uvicorn" }, ] @@ -705,6 +831,7 @@ dev = [ [package.dev-dependencies] dev = [ { name = "flake8" }, + { name = "hypothesis" }, { name = "mock" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -715,6 +842,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.16.4" }, { name = "authlib", specifier = ">=1.3.0" }, { name = "bcrypt", specifier = ">=4.0.0,<5.0.0" }, + { name = "bedrock-agentcore", specifier = ">=0.0.6" }, { name = "boto3", specifier = ">=1.40.30" }, { name = "cryptography", specifier = ">=45.0.6" }, { name = "email-validator", specifier = ">=2.1.0" }, @@ -723,6 +851,7 @@ requires-dist = [ { name = "gunicorn", specifier = ">=21.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mako", specifier = ">=1.3.10" }, + { name = "mcp", specifier = ">=1.26.0" }, { name = "opensearch-py", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, @@ -736,6 +865,7 @@ requires-dist = [ { name = "sample-sheet", specifier = ">=0.13.0" }, { name = "smart-open", specifier = ">=7.3.1" }, { name = "sqlmodel", specifier = ">=0.0.24" }, + { name = "strands-agents", specifier = ">=0.1.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, ] provides-extras = ["dev"] @@ -743,6 +873,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ { name = "flake8", specifier = ">=7.3.0" }, + { name = "hypothesis", specifier = ">=6.100.0" }, { name = "mock", specifier = ">=5.2.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-cov", specifier = ">=6.2.1" }, @@ -764,6 +895,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/e0/69fd114c607b0323d3f864ab4a5ecb87d76ec5a172d2e36a739c8baebea1/opensearch_py-3.0.0-py3-none-any.whl", hash = "sha256:842bf5d56a4a0d8290eda9bb921c50f3080e5dc4e5fefb9c9648289da3f6a8bb", size = 371491, upload-time = "2025-06-17T05:39:46.539Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.61b0" +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/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/8f/8dedba66100cda58af057926449a5e58e6c008bec02bc2746c03c3d85dcd/opentelemetry_instrumentation_threading-0.61b0.tar.gz", hash = "sha256:38e0263c692d15a7a458b3fa0286d29290448fa4ac4c63045edac438c6113433", size = 9163, upload-time = "2026-03-04T14:20:50.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/77/c06d960aede1a014812aa4fafde0ae546d790f46416fbeafa2b32095aae3/opentelemetry_instrumentation_threading-0.61b0-py3-none-any.whl", hash = "sha256:735f4a1dc964202fc8aff475efc12bb64e6566f22dff52d5cb5de864b3fe1a70", size = 9337, upload-time = "2026-03-04T14:19:57.983Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.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/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -902,6 +1102,20 @@ 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.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymysql" version = "1.1.1" @@ -999,6 +1213,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { 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.2" @@ -1016,6 +1243,19 @@ 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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1090,6 +1330,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/9f/0f13511b27c3548372d9679637f1120e690370baf6ed890755eb73d9387b/rignore-0.6.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2252d603550d529362c569b10401ab32536613517e7e1df0e4477fe65498245", size = 974567, upload-time = "2025-07-13T11:57:26.592Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -1104,14 +1410,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.16.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" } +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] [[package]] @@ -1178,6 +1484,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 = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.41" @@ -1212,6 +1527,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, +] + [[package]] name = "starlette" version = "0.49.1" @@ -1224,6 +1552,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, ] +[[package]] +name = "strands-agents" +version = "1.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/af/76200d7fe69417ebfbf9d3b65c898609a7d74d98d288cce82ca4734591d2/strands_agents-1.33.0.tar.gz", hash = "sha256:1707ae217c2e2700caedafd22ed1d4385cefe90d3debffac4de20cce76cfa676", size = 776194, upload-time = "2026-03-24T19:17:42.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/99/b3056a03c7d6fb04c1d10afb8fa966b6a5fbce836e264faf663d136f69dd/strands_agents-1.33.0-py3-none-any.whl", hash = "sha256:037406bc86416d2ef3274658faacc35cb62fc5cc13b581d7049796b5e2cb6c33", size = 387070, upload-time = "2026-03-24T19:17:40.697Z" }, +] + [[package]] name = "tabulate" version = "0.9.0" @@ -1325,6 +1676,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, ] +[[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/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 = "watchfiles" version = "1.1.0" @@ -1437,3 +1809,12 @@ wheels = [ { 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 = "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" }, +]