diff --git a/.env.example b/.env.example index a3ed1e9..268e443 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,265 @@ +# ============================================================================= +# HuMCP - Environment Variables +# ============================================================================= +# Copy this file to .env: cp .env.example .env +# +# HuMCP is standalone — set only the vars for tools you want to use. +# Tools without their required keys will load but return errors when called. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- MCP_SERVER_URL=http://localhost:8080 -GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com -GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider +# ----------------------------------------------------------------------------- +# Authentication (optional) +# ----------------------------------------------------------------------------- +# Google OAuth — enables /login flow and Swagger UI auth +# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +# GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret +# AUTH_ENABLED=false + +# JWT verification — for service-to-service (e.g. graphite integration) +# JWT_SECRET_KEY=your-jwt-secret-key + +# API key — restricts all REST endpoints to requests with this key +# SERVICE_API_KEY=dev-api-key-change-in-production + +# ----------------------------------------------------------------------------- +# Storage — S3-compatible (MinIO, AWS S3, GCS) +# ----------------------------------------------------------------------------- +STORAGE_ENDPOINT=localhost:9000 # AWS S3: s3.amazonaws.com +STORAGE_ACCESS_KEY=minioadmin # AWS: AWS_ACCESS_KEY_ID +STORAGE_SECRET_KEY=minioadmin # AWS: AWS_SECRET_ACCESS_KEY +STORAGE_SECURE=false # true for AWS S3 / GCS +# STORAGE_REGION=us-east-1 +# STORAGE_ALLOWED_BUCKETS=my-bucket + +# ----------------------------------------------------------------------------- +# Search +# ----------------------------------------------------------------------------- +# TAVILY_API_KEY=your-tavily-api-key +# EXA_API_KEY=your-exa-api-key +# BRAVE_API_KEY=your-brave-api-key +# SERPAPI_API_KEY=your-serpapi-api-key +# SERPER_API_KEY=your-serper-api-key +# JINA_API_KEY=your-jina-api-key +# LINKUP_API_KEY=your-linkup-api-key +# SEARXNG_BASE_URL=http://localhost:8888 +# SELTZ_API_KEY=your-seltz-api-key +# VALYU_API_KEY=your-valyu-api-key + +# ----------------------------------------------------------------------------- +# Web Scraping +# ----------------------------------------------------------------------------- +# FIRECRAWL_API_KEY=your-firecrawl-api-key +# SPIDER_API_KEY=your-spider-api-key +# AGENTQL_API_KEY=your-agentql-api-key +# BROWSERBASE_API_KEY=your-browserbase-api-key +# BROWSERBASE_PROJECT_ID=your-project-id +# APIFY_API_TOKEN=your-apify-token +# SGAI_API_KEY=your-scrapegraph-api-key +# BRIGHTDATA_API_KEY=your-brightdata-api-key +# BRIGHTDATA_WEB_UNLOCKER_ZONE=your-zone +# OXYLABS_USERNAME=your-username +# OXYLABS_PASSWORD=your-password + +# ----------------------------------------------------------------------------- +# LLMs +# ----------------------------------------------------------------------------- +# OPENAI_API_KEY=your-openai-api-key +# ANTHROPIC_API_KEY=your-anthropic-api-key +# GOOGLE_API_KEY=your-google-ai-api-key +# OLLAMA_HOST=http://localhost:11434 + +# ----------------------------------------------------------------------------- +# Google Workspace +# ----------------------------------------------------------------------------- +# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com +# GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret +# GOOGLE_CLOUD_PROJECT=your-gcp-project-id +# GOOGLE_MAPS_API_KEY=your-maps-api-key + +# ----------------------------------------------------------------------------- +# Messaging +# ----------------------------------------------------------------------------- +# Slack +# SLACK_TOKEN=xoxb-your-slack-bot-token + +# Discord +# DISCORD_BOT_TOKEN=your-discord-bot-token + +# Telegram +# TELEGRAM_BOT_TOKEN=your-telegram-bot-token + +# WhatsApp (Meta Cloud API) +# WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +# WHATSAPP_TOKEN=your-whatsapp-token + +# Email (SMTP) +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USERNAME=your-email@gmail.com +# SMTP_PASSWORD=your-app-password + +# Resend +# RESEND_API_KEY=your-resend-api-key + +# Twilio (SMS) +# TWILIO_ACCOUNT_SID=your-account-sid +# TWILIO_AUTH_TOKEN=your-auth-token +# TWILIO_FROM_NUMBER=+1234567890 + +# Webex +# WEBEX_ACCESS_TOKEN=your-webex-access-token + +# ----------------------------------------------------------------------------- +# Social +# ----------------------------------------------------------------------------- +# X / Twitter +# X_API_KEY=your-api-key +# X_API_SECRET=your-api-secret +# X_ACCESS_TOKEN=your-access-token +# X_ACCESS_TOKEN_SECRET=your-access-token-secret +# X_BEARER_TOKEN=your-bearer-token + +# Reddit +# REDDIT_CLIENT_ID=your-client-id +# REDDIT_CLIENT_SECRET=your-client-secret +# REDDIT_USER_AGENT=your-app-name/1.0 + +# YouTube +# YOUTUBE_API_KEY=your-youtube-api-key + +# ----------------------------------------------------------------------------- +# Project Management +# ----------------------------------------------------------------------------- +# GitHub +# GITHUB_TOKEN=ghp_your-github-token + +# Jira / Confluence +# JIRA_URL=https://your-org.atlassian.net +# JIRA_USERNAME=your-email@example.com +# JIRA_API_TOKEN=your-jira-api-token +# CONFLUENCE_URL=https://your-org.atlassian.net +# CONFLUENCE_USERNAME=your-email@example.com +# CONFLUENCE_API_TOKEN=your-confluence-api-token + +# Linear +# LINEAR_API_KEY=your-linear-api-key + +# Notion +# NOTION_API_KEY=secret_your-notion-key + +# Todoist +# TODOIST_API_KEY=your-todoist-api-key + +# Trello +# TRELLO_API_KEY=your-trello-api-key +# TRELLO_TOKEN=your-trello-token + +# ClickUp +# CLICKUP_API_KEY=your-clickup-api-key + +# Zendesk +# ZENDESK_SUBDOMAIN=your-subdomain +# ZENDESK_EMAIL=your-email@example.com +# ZENDESK_API_TOKEN=your-zendesk-api-token + +# Bitbucket +# BITBUCKET_USERNAME=your-username +# BITBUCKET_APP_PASSWORD=your-app-password + +# ----------------------------------------------------------------------------- +# Database +# ----------------------------------------------------------------------------- +# PostgreSQL +# DATABASE_URL=postgresql://user:password@localhost:5432/mydb + +# Redshift +# REDSHIFT_HOST=your-cluster.redshift.amazonaws.com +# REDSHIFT_DATABASE=your-database +# REDSHIFT_USER=your-user +# REDSHIFT_PASSWORD=your-password +# REDSHIFT_PORT=5439 + +# Neo4j +# NEO4J_URI=bolt://localhost:7687 +# NEO4J_USERNAME=neo4j +# NEO4J_PASSWORD=your-password + +# ----------------------------------------------------------------------------- +# Cloud +# ----------------------------------------------------------------------------- +# AWS +# AWS_ACCESS_KEY_ID=your-access-key-id +# AWS_SECRET_ACCESS_KEY=your-secret-access-key +# AWS_DEFAULT_REGION=us-east-1 +# AWS_SES_FROM_EMAIL=noreply@yourdomain.com + +# E2B (code sandboxes) +# E2B_API_KEY=your-e2b-api-key + +# Daytona +# DAYTONA_API_KEY=your-daytona-api-key +# DAYTONA_SERVER_URL=https://your-daytona-server + +# Airflow +# AIRFLOW_BASE_URL=http://localhost:8080 +# AIRFLOW_USERNAME=airflow +# AIRFLOW_PASSWORD=airflow + +# ----------------------------------------------------------------------------- +# Finance +# ----------------------------------------------------------------------------- +# FINANCIAL_DATASETS_API_KEY=your-api-key +# OPENBB_TOKEN=your-openbb-token +# EVM_RPC_URL=https://mainnet.infura.io/v3/your-project-id + +# ----------------------------------------------------------------------------- +# Media & Image +# ----------------------------------------------------------------------------- +# REPLICATE_API_TOKEN=your-replicate-token +# FAL_KEY=your-fal-api-key +# LUMAAI_API_KEY=your-lumaai-api-key +# UNSPLASH_ACCESS_KEY=your-unsplash-access-key +# GIPHY_API_KEY=your-giphy-api-key + +# ----------------------------------------------------------------------------- +# Audio +# ----------------------------------------------------------------------------- +# ELEVEN_LABS_API_KEY=your-elevenlabs-api-key +# CARTESIA_API_KEY=your-cartesia-api-key +# SPOTIFY_CLIENT_ID=your-spotify-client-id +# SPOTIFY_CLIENT_SECRET=your-spotify-client-secret +# DESI_VOCAL_API_KEY=your-desi-vocal-api-key + +# ----------------------------------------------------------------------------- +# Memory +# ----------------------------------------------------------------------------- +# MEM0_API_KEY=your-mem0-api-key +# ZEP_API_KEY=your-zep-api-key + +# ----------------------------------------------------------------------------- +# Calendar +# ----------------------------------------------------------------------------- +# CALCOM_API_KEY=your-calcom-api-key +# CALCOM_BASE_URL=https://api.cal.com/v1 +# ZOOM_ACCOUNT_ID=your-account-id +# ZOOM_CLIENT_ID=your-client-id +# ZOOM_CLIENT_SECRET=your-client-secret + +# ----------------------------------------------------------------------------- +# E-commerce +# ----------------------------------------------------------------------------- +# SHOPIFY_STORE_URL=https://your-store.myshopify.com +# SHOPIFY_ACCESS_TOKEN=your-access-token +# BRANDFETCH_API_KEY=your-brandfetch-api-key -AUTH_ENABLED=false \ No newline at end of file +# ----------------------------------------------------------------------------- +# Local Tools +# ----------------------------------------------------------------------------- +# Allow shell/file tools to use absolute paths (default: false for safety) +# HUMCP_ALLOW_ABSOLUTE_PATHS=false +# STORAGE_ALLOW_ABSOLUTE_PATHS=false diff --git a/CLAUDE.md b/CLAUDE.md index 6c799c0..4dc4868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,21 +39,45 @@ HuMCP exposes tools via both MCP (Model Context Protocol) at `/mcp` and REST at ### Core Library (`src/humcp/`) -- **`server.py`** - `create_app()` creates FastAPI app, loads tool modules, registers with FastMCP -- **`decorator.py`** - `@tool(category="...")` marks functions for discovery. Category auto-detects from parent folder if not specified. Tool name = function name (used by FastMCP) -- **`registry.py`** - `RegisteredTool` NamedTuple wraps FastMCP's `FunctionTool` with category +- **`server.py`** - `create_app()` creates FastAPI app, loads tool modules, discovers MCP Apps, registers with FastMCP, mounts MCP/REST/OAuth routes +- **`decorator.py`** - `@tool(category="...", app="...")` marks functions for discovery. Category auto-detects from parent folder, `app` defaults to filename stem. Tool name = function name +- **`registry.py`** - `RegisteredTool` NamedTuple wraps FastMCP's `FunctionTool` with `category` and `app` fields - **`routes.py`** - Generates REST endpoints from registered tools using `FunctionTool.parameters` - **`config.py`** - Tool filtering via `config/tools.yaml` (include/exclude with wildcard support) - **`skills.py`** - Discovers `SKILL.md` files for category metadata - **`schemas.py`** - Pydantic response models for API endpoints +- **`credentials.py`** - `resolve_credential("KEY_NAME")` resolves credentials from env vars +- **`storage_path.py`** - `minio://` URL resolution utilities. `is_storage_path()`, `parse_storage_path()`, `resolve_path()` (async context manager that downloads storage objects to temp files) +- **`auth.py`** - Google OAuth provider via `create_auth_provider()`, `is_auth_enabled()`, `get_current_user_id()` (resolves user from MCP access token or REST ContextVar) +- **`middleware.py`** - `APIKeyMiddleware` validates `X-API-Key` header for REST requests. Extracts user identity from Bearer JWT into ContextVar. Public paths (`/`, `/docs`, `/playground`, etc.) are exempt +- **`permissions.py`** - `require_auth()` returns user UUID or raises 401 when `TOOLSET_REQUIRE_AUTH=true`. `check_permission()` is a stub that delegates to `require_auth()` (upgrade to full IAM when available). `STRICT_PERMISSIONS=true` rejects all permission checks +- **`playground.py`** - `get_playground_html()` returns a self-contained HTML page for interactive tool browsing and execution at `/playground` + +### Authentication Modes + +The server supports four auth modes, selected by environment variables: + +1. **JWT mode** - Set `JWT_SECRET_KEY`: uses `JWTVerifier` for MCP auth (service-to-service). Middleware decodes Bearer JWTs on REST requests +2. **Google OAuth** - Set `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` + `AUTH_ENABLED=true`: `GoogleProvider` handles browser login flow with Google scopes (Gmail, Calendar, Drive, Tasks, Docs, Sheets, etc.) +3. **API key** - Set `SERVICE_API_KEY`: `APIKeyMiddleware` requires `X-API-Key` header on all non-public REST requests. Uses constant-time comparison +4. **No auth** - Default dev mode when none of the above are configured ### Tool Discovery Flow 1. `create_app()` loads Python modules from `src/tools/` recursively 2. Functions with `@tool()` decorator are discovered via `_humcp_tool` attribute 3. Each tool is registered with FastMCP via `mcp.tool()(func)` which creates a `FunctionTool` -4. `RegisteredTool(tool=fn_tool, category=...)` pairs the FunctionTool with its category +4. `RegisteredTool(tool=fn_tool, category=..., app=...)` pairs the FunctionTool with its category and app 5. REST routes are generated from `FunctionTool.parameters` (JSON Schema) +6. Tools are filtered by `config/tools.yaml` before route registration + +### Tool Categories (24) + +api, audio, builder, calendar, cloud, data, database, ecommerce, files, finance, google, image, llm, local, media, memory, messaging, project_management, research, search, social, storage, weather, web_scraping + +### App Grouping + +The `@tool()` decorator accepts an `app` parameter (defaults to the filename stem). `RegisteredTool` carries this `app` field. REST responses include `apps` grouping so clients can group related tools by their source module. ### Adding New Tools @@ -62,7 +86,7 @@ Create a `.py` file in `src/tools//`: ```python from src.humcp.decorator import tool -@tool() # category auto-detected from folder name +@tool() # category auto-detected from folder name, app from filename async def my_tool(param: str) -> dict: """Tool description (used by FastMCP and Swagger).""" return {"success": True, "data": {"result": param}} @@ -80,6 +104,26 @@ return {"success": True, "data": {...}} return {"success": False, "error": "Error message"} ``` +### MCP Apps System + +Interactive UI panels rendered alongside tool results: + +- HTML bundles live in `src/apps/{category}/{tool_name}.html` +- Registered as `ui://` MCP resources on the FastMCP server +- Served via `/apps/{tool_name}` REST endpoint and listed at `/apps` +- Claude.ai renders them as interactive UI panels via iframe + JSON-RPC messaging (`ui/ready`, `ui/initialize`, `ui/toolResult`) +- The Playground (`/playground`) also renders app HTML for tools that have them + +### Builder Subsystem + +Custom tool creation at runtime (`src/tools/builder/`): + +- `tools.py` - `tool_builder_*` functions for creating, listing, updating, deleting custom tools +- `sandbox.py` - Sandboxed execution via RestrictedPython +- `storage.py` - Persistence for custom tool definitions +- `schemas.py` - Pydantic models for builder requests +- All builder endpoints require authentication + ### Skills Add `SKILL.md` in tool category folders with YAML frontmatter: @@ -105,3 +149,22 @@ exclude: ``` Empty config = load all tools. + +### Key Environment Variables + +| Variable | Purpose | +|---|---| +| `MCP_SERVER_URL` | Advertised MCP endpoint URL (default `http://0.0.0.0:8080/mcp`) | +| `AUTH_ENABLED` | Enable/disable Google OAuth (default `true`) | +| `JWT_SECRET_KEY` | Enables JWT auth mode for MCP and REST Bearer tokens | +| `SERVICE_API_KEY` | Enables API key middleware for REST endpoints | +| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret | +| `TOOLSET_REQUIRE_AUTH` | When `true`, `require_auth()` raises 401 for unauthenticated requests | +| `STRICT_PERMISSIONS` | When `true`, `check_permission()` rejects all calls (no IAM backend) | +| `STORAGE_ENDPOINT` | MinIO/S3 storage endpoint | +| `STORAGE_ACCESS_KEY` | MinIO/S3 access key | +| `STORAGE_SECRET_KEY` | MinIO/S3 secret key | +| `STORAGE_SECURE` | Use HTTPS for storage connections | +| `DB_READ_ONLY` | Database read-only mode (default `true`) | +| `HTTP_ALLOW_PRIVATE` | Allow HTTP requests to private IPs (default `false`) | diff --git a/pyproject.toml b/pyproject.toml index a93ad44..a1bf241 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "fastapi>=0.104.0", "pydantic>=2.0.0", "uvicorn>=0.24.0", - "fastmcp>=2.13.0.2", + "fastmcp>=3.0.0", "pandas>=2.3.3", "duckdb>=1.4.2", "tavily-python>=0.7.13", @@ -17,6 +17,68 @@ dependencies = [ "google-api-python-client>=2.168.0", "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.2", + "minio>=7.0.0", + "slack-sdk>=3.33.0", + "httpx>=0.28.1", + "authlib>=1.3.0", + "python-jose[cryptography]>=3.3.0", + # Search + "duckduckgo-search", + "baidusearch", + "exa-py", + "google-search-results", + # Web Scraping + "firecrawl-py", + "newspaper4k", + "trafilatura", + "apify-client", + "linkup-sdk", + # Messaging + "resend", + "twilio", + # Project Management + "jira", + "atlassian-python-api", + "todoist-api-python", + # Finance + "yfinance", + "web3", + # Social + "praw", + "youtube-transcript-api", + # Research + "arxiv", + "pypdf", + "wikipedia", + # Memory + "mem0ai", + "zep-cloud", + # Media/Image + "replicate", + "lumaai", + "fal-client", + "pillow", + "moviepy", + # Audio + "elevenlabs", + "cartesia", + # Cloud + "boto3", + "docker", + "e2b-code-interpreter", + # Database + "neo4j", + "sqlalchemy[asyncio]", + "asyncpg", + # LLMs + "anthropic", + "openai", + "google-genai", + "ollama", + # Other + "restrictedpython", + "pytesseract", + "python-dotenv", ] [dependency-groups] diff --git a/src/apps/.gitkeep b/src/apps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/api/http_request.html b/src/apps/api/http_request.html new file mode 100644 index 0000000..a9c1706 --- /dev/null +++ b/src/apps/api/http_request.html @@ -0,0 +1,208 @@ + + + + + + HTTP Response + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/cartesia_list_voices.html b/src/apps/audio/cartesia_list_voices.html new file mode 100644 index 0000000..4f37238 --- /dev/null +++ b/src/apps/audio/cartesia_list_voices.html @@ -0,0 +1,169 @@ + + + + + + Cartesia Voices + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/cartesia_text_to_speech.html b/src/apps/audio/cartesia_text_to_speech.html new file mode 100644 index 0000000..2b1ab49 --- /dev/null +++ b/src/apps/audio/cartesia_text_to_speech.html @@ -0,0 +1,149 @@ + + + + + + Cartesia Text to Speech + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/desi_vocal_list_voices.html b/src/apps/audio/desi_vocal_list_voices.html new file mode 100644 index 0000000..428f82d --- /dev/null +++ b/src/apps/audio/desi_vocal_list_voices.html @@ -0,0 +1,187 @@ + + + + + + DesiVocal Voices + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/desi_vocal_tts.html b/src/apps/audio/desi_vocal_tts.html new file mode 100644 index 0000000..a476e96 --- /dev/null +++ b/src/apps/audio/desi_vocal_tts.html @@ -0,0 +1,130 @@ + + + + + + DesiVocal Text to Speech + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/elevenlabs_list_voices.html b/src/apps/audio/elevenlabs_list_voices.html new file mode 100644 index 0000000..fc2f247 --- /dev/null +++ b/src/apps/audio/elevenlabs_list_voices.html @@ -0,0 +1,153 @@ + + + + + + ElevenLabs Voices + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/elevenlabs_text_to_speech.html b/src/apps/audio/elevenlabs_text_to_speech.html new file mode 100644 index 0000000..0d0f8ec --- /dev/null +++ b/src/apps/audio/elevenlabs_text_to_speech.html @@ -0,0 +1,156 @@ + + + + + + ElevenLabs Text to Speech + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/mlx_transcribe.html b/src/apps/audio/mlx_transcribe.html new file mode 100644 index 0000000..bb77d22 --- /dev/null +++ b/src/apps/audio/mlx_transcribe.html @@ -0,0 +1,139 @@ + + + + + + MLX Transcription + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/spotify_get_playlist.html b/src/apps/audio/spotify_get_playlist.html new file mode 100644 index 0000000..9b6ad01 --- /dev/null +++ b/src/apps/audio/spotify_get_playlist.html @@ -0,0 +1,183 @@ + + + + + + Spotify Playlist + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/spotify_get_track.html b/src/apps/audio/spotify_get_track.html new file mode 100644 index 0000000..7ec6e15 --- /dev/null +++ b/src/apps/audio/spotify_get_track.html @@ -0,0 +1,165 @@ + + + + + + Spotify Track + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/audio/spotify_search.html b/src/apps/audio/spotify_search.html new file mode 100644 index 0000000..cb69392 --- /dev/null +++ b/src/apps/audio/spotify_search.html @@ -0,0 +1,187 @@ + + + + + + Spotify Search + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calculator/absolute_value.html b/src/apps/calculator/absolute_value.html new file mode 100644 index 0000000..b4fb7e3 --- /dev/null +++ b/src/apps/calculator/absolute_value.html @@ -0,0 +1,114 @@ + + + + + + Absolute Value - Calculator + + + + +
+
Waiting for data...
+ + + +
+ + + + diff --git a/src/apps/calculator/add.html b/src/apps/calculator/add.html new file mode 100644 index 0000000..a0ccaf3 --- /dev/null +++ b/src/apps/calculator/add.html @@ -0,0 +1,122 @@ + + + + + + Add - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/divide.html b/src/apps/calculator/divide.html new file mode 100644 index 0000000..82be113 --- /dev/null +++ b/src/apps/calculator/divide.html @@ -0,0 +1,119 @@ + + + + + + Divide - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/exponentiate.html b/src/apps/calculator/exponentiate.html new file mode 100644 index 0000000..ac00e5d --- /dev/null +++ b/src/apps/calculator/exponentiate.html @@ -0,0 +1,115 @@ + + + + + + Exponentiate - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/factorial.html b/src/apps/calculator/factorial.html new file mode 100644 index 0000000..0c6ce85 --- /dev/null +++ b/src/apps/calculator/factorial.html @@ -0,0 +1,144 @@ + + + + + + Factorial - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/greatest_common_divisor.html b/src/apps/calculator/greatest_common_divisor.html new file mode 100644 index 0000000..df48dbe --- /dev/null +++ b/src/apps/calculator/greatest_common_divisor.html @@ -0,0 +1,128 @@ + + + + + + Greatest Common Divisor - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/is_prime.html b/src/apps/calculator/is_prime.html new file mode 100644 index 0000000..a093aef --- /dev/null +++ b/src/apps/calculator/is_prime.html @@ -0,0 +1,145 @@ + + + + + + Is Prime - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/logarithm.html b/src/apps/calculator/logarithm.html new file mode 100644 index 0000000..e7db4c6 --- /dev/null +++ b/src/apps/calculator/logarithm.html @@ -0,0 +1,128 @@ + + + + + + Logarithm - Calculator + + + + +
+
Waiting for data...
+ + + +
+ + + + diff --git a/src/apps/calculator/modulo.html b/src/apps/calculator/modulo.html new file mode 100644 index 0000000..1aa8253 --- /dev/null +++ b/src/apps/calculator/modulo.html @@ -0,0 +1,109 @@ + + + + + + Modulo - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/multiply.html b/src/apps/calculator/multiply.html new file mode 100644 index 0000000..27a12ab --- /dev/null +++ b/src/apps/calculator/multiply.html @@ -0,0 +1,113 @@ + + + + + + Multiply - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/square_root.html b/src/apps/calculator/square_root.html new file mode 100644 index 0000000..c80c9db --- /dev/null +++ b/src/apps/calculator/square_root.html @@ -0,0 +1,111 @@ + + + + + + Square Root - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calculator/subtract.html b/src/apps/calculator/subtract.html new file mode 100644 index 0000000..d4bfc7f --- /dev/null +++ b/src/apps/calculator/subtract.html @@ -0,0 +1,112 @@ + + + + + + Subtract - Calculator + + + + +
+
Waiting for data...
+ + +
+ + + + diff --git a/src/apps/calendar/calcom_create_booking.html b/src/apps/calendar/calcom_create_booking.html new file mode 100644 index 0000000..46afeb8 --- /dev/null +++ b/src/apps/calendar/calcom_create_booking.html @@ -0,0 +1,146 @@ + + + + + + Cal.com Booking Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calendar/calcom_get_availability.html b/src/apps/calendar/calcom_get_availability.html new file mode 100644 index 0000000..4c51efe --- /dev/null +++ b/src/apps/calendar/calcom_get_availability.html @@ -0,0 +1,160 @@ + + + + + + Cal.com Availability + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calendar/calcom_list_bookings.html b/src/apps/calendar/calcom_list_bookings.html new file mode 100644 index 0000000..d321e1e --- /dev/null +++ b/src/apps/calendar/calcom_list_bookings.html @@ -0,0 +1,183 @@ + + + + + + Cal.com Bookings + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calendar/zoom_create_meeting.html b/src/apps/calendar/zoom_create_meeting.html new file mode 100644 index 0000000..41832dd --- /dev/null +++ b/src/apps/calendar/zoom_create_meeting.html @@ -0,0 +1,156 @@ + + + + + + Zoom Meeting Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calendar/zoom_get_meeting.html b/src/apps/calendar/zoom_get_meeting.html new file mode 100644 index 0000000..d1840cf --- /dev/null +++ b/src/apps/calendar/zoom_get_meeting.html @@ -0,0 +1,182 @@ + + + + + + Zoom Meeting Details + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/calendar/zoom_list_meetings.html b/src/apps/calendar/zoom_list_meetings.html new file mode 100644 index 0000000..3fc4d60 --- /dev/null +++ b/src/apps/calendar/zoom_list_meetings.html @@ -0,0 +1,193 @@ + + + + + + Zoom Meetings + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/airflow_get_dag_run.html b/src/apps/cloud/airflow_get_dag_run.html new file mode 100644 index 0000000..6adfd08 --- /dev/null +++ b/src/apps/cloud/airflow_get_dag_run.html @@ -0,0 +1,165 @@ + + + + + + DAG Run Status + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/airflow_list_dags.html b/src/apps/cloud/airflow_list_dags.html new file mode 100644 index 0000000..d77d1c3 --- /dev/null +++ b/src/apps/cloud/airflow_list_dags.html @@ -0,0 +1,169 @@ + + + + + + Airflow DAGs + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/airflow_trigger_dag.html b/src/apps/cloud/airflow_trigger_dag.html new file mode 100644 index 0000000..a72627b --- /dev/null +++ b/src/apps/cloud/airflow_trigger_dag.html @@ -0,0 +1,156 @@ + + + + + + DAG Triggered + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/aws_lambda_invoke.html b/src/apps/cloud/aws_lambda_invoke.html new file mode 100644 index 0000000..af0f30b --- /dev/null +++ b/src/apps/cloud/aws_lambda_invoke.html @@ -0,0 +1,147 @@ + + + + + + Lambda Invocation Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/aws_lambda_list_functions.html b/src/apps/cloud/aws_lambda_list_functions.html new file mode 100644 index 0000000..47c6597 --- /dev/null +++ b/src/apps/cloud/aws_lambda_list_functions.html @@ -0,0 +1,163 @@ + + + + + + Lambda Functions + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/aws_ses_send_email.html b/src/apps/cloud/aws_ses_send_email.html new file mode 100644 index 0000000..154320d --- /dev/null +++ b/src/apps/cloud/aws_ses_send_email.html @@ -0,0 +1,136 @@ + + + + + + Email Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/daytona_create_workspace.html b/src/apps/cloud/daytona_create_workspace.html new file mode 100644 index 0000000..c3d799a --- /dev/null +++ b/src/apps/cloud/daytona_create_workspace.html @@ -0,0 +1,130 @@ + + + + + + Workspace Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/daytona_list_workspaces.html b/src/apps/cloud/daytona_list_workspaces.html new file mode 100644 index 0000000..0452c2b --- /dev/null +++ b/src/apps/cloud/daytona_list_workspaces.html @@ -0,0 +1,172 @@ + + + + + + Daytona Workspaces + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/daytona_run_command.html b/src/apps/cloud/daytona_run_command.html new file mode 100644 index 0000000..7a65287 --- /dev/null +++ b/src/apps/cloud/daytona_run_command.html @@ -0,0 +1,137 @@ + + + + + + Daytona Command Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/docker_list_containers.html b/src/apps/cloud/docker_list_containers.html new file mode 100644 index 0000000..4b47afd --- /dev/null +++ b/src/apps/cloud/docker_list_containers.html @@ -0,0 +1,185 @@ + + + + + + Docker Containers + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/docker_run_container.html b/src/apps/cloud/docker_run_container.html new file mode 100644 index 0000000..6f3feab --- /dev/null +++ b/src/apps/cloud/docker_run_container.html @@ -0,0 +1,136 @@ + + + + + + Container Started + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/docker_stop_container.html b/src/apps/cloud/docker_stop_container.html new file mode 100644 index 0000000..769551c --- /dev/null +++ b/src/apps/cloud/docker_stop_container.html @@ -0,0 +1,130 @@ + + + + + + Container Stopped + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/e2b_run_code.html b/src/apps/cloud/e2b_run_code.html new file mode 100644 index 0000000..5695e54 --- /dev/null +++ b/src/apps/cloud/e2b_run_code.html @@ -0,0 +1,159 @@ + + + + + + Code Execution + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/cloud/e2b_run_command.html b/src/apps/cloud/e2b_run_command.html new file mode 100644 index 0000000..4cec6f2 --- /dev/null +++ b/src/apps/cloud/e2b_run_command.html @@ -0,0 +1,142 @@ + + + + + + E2B Command Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/data/query_csv_file.html b/src/apps/data/query_csv_file.html new file mode 100644 index 0000000..81f9cd1 --- /dev/null +++ b/src/apps/data/query_csv_file.html @@ -0,0 +1,141 @@ + + + + + + CSV Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/data/read_csv_file.html b/src/apps/data/read_csv_file.html new file mode 100644 index 0000000..6e9db52 --- /dev/null +++ b/src/apps/data/read_csv_file.html @@ -0,0 +1,150 @@ + + + + + + CSV Data + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/describe_table.html b/src/apps/database/describe_table.html new file mode 100644 index 0000000..78aaf2d --- /dev/null +++ b/src/apps/database/describe_table.html @@ -0,0 +1,141 @@ + + + + + + Table Structure + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/duckdb_list_tables.html b/src/apps/database/duckdb_list_tables.html new file mode 100644 index 0000000..1e958d0 --- /dev/null +++ b/src/apps/database/duckdb_list_tables.html @@ -0,0 +1,148 @@ + + + + + + DuckDB Tables + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/duckdb_query.html b/src/apps/database/duckdb_query.html new file mode 100644 index 0000000..b32d924 --- /dev/null +++ b/src/apps/database/duckdb_query.html @@ -0,0 +1,140 @@ + + + + + + DuckDB Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/duckdb_read_file.html b/src/apps/database/duckdb_read_file.html new file mode 100644 index 0000000..8227f40 --- /dev/null +++ b/src/apps/database/duckdb_read_file.html @@ -0,0 +1,86 @@ + + + + + + DuckDB Read File + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/database/execute_query.html b/src/apps/database/execute_query.html new file mode 100644 index 0000000..192bc87 --- /dev/null +++ b/src/apps/database/execute_query.html @@ -0,0 +1,140 @@ + + + + + + SQL Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/list_tables.html b/src/apps/database/list_tables.html new file mode 100644 index 0000000..db6b16d --- /dev/null +++ b/src/apps/database/list_tables.html @@ -0,0 +1,138 @@ + + + + + + Database Tables + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/neo4j_get_schema.html b/src/apps/database/neo4j_get_schema.html new file mode 100644 index 0000000..f51c124 --- /dev/null +++ b/src/apps/database/neo4j_get_schema.html @@ -0,0 +1,171 @@ + + + + + + Neo4j Schema + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/neo4j_query.html b/src/apps/database/neo4j_query.html new file mode 100644 index 0000000..438aa5d --- /dev/null +++ b/src/apps/database/neo4j_query.html @@ -0,0 +1,148 @@ + + + + + + Neo4j Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/redshift_query.html b/src/apps/database/redshift_query.html new file mode 100644 index 0000000..f3a9f76 --- /dev/null +++ b/src/apps/database/redshift_query.html @@ -0,0 +1,149 @@ + + + + + + Redshift Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/database/sql_query.html b/src/apps/database/sql_query.html new file mode 100644 index 0000000..323303c --- /dev/null +++ b/src/apps/database/sql_query.html @@ -0,0 +1,150 @@ + + + + + + SQL Query Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/brandfetch_get_brand.html b/src/apps/ecommerce/brandfetch_get_brand.html new file mode 100644 index 0000000..4ff8af6 --- /dev/null +++ b/src/apps/ecommerce/brandfetch_get_brand.html @@ -0,0 +1,241 @@ + + + + + + Brand Details + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/shopify_create_product.html b/src/apps/ecommerce/shopify_create_product.html new file mode 100644 index 0000000..417c537 --- /dev/null +++ b/src/apps/ecommerce/shopify_create_product.html @@ -0,0 +1,206 @@ + + + + + + Product Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/shopify_get_customers.html b/src/apps/ecommerce/shopify_get_customers.html new file mode 100644 index 0000000..c9870c3 --- /dev/null +++ b/src/apps/ecommerce/shopify_get_customers.html @@ -0,0 +1,180 @@ + + + + + + Shopify Customers + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/shopify_get_product.html b/src/apps/ecommerce/shopify_get_product.html new file mode 100644 index 0000000..ff4e504 --- /dev/null +++ b/src/apps/ecommerce/shopify_get_product.html @@ -0,0 +1,228 @@ + + + + + + Product Details + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/shopify_list_orders.html b/src/apps/ecommerce/shopify_list_orders.html new file mode 100644 index 0000000..9135b56 --- /dev/null +++ b/src/apps/ecommerce/shopify_list_orders.html @@ -0,0 +1,189 @@ + + + + + + Shopify Orders + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/ecommerce/shopify_list_products.html b/src/apps/ecommerce/shopify_list_products.html new file mode 100644 index 0000000..b2b12c4 --- /dev/null +++ b/src/apps/ecommerce/shopify_list_products.html @@ -0,0 +1,179 @@ + + + + + + Shopify Products + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/files/convert_pdf_to_markdown.html b/src/apps/files/convert_pdf_to_markdown.html new file mode 100644 index 0000000..0016ff6 --- /dev/null +++ b/src/apps/files/convert_pdf_to_markdown.html @@ -0,0 +1,143 @@ + + + + + + PDF to Markdown + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/files/markdown_to_table.html b/src/apps/files/markdown_to_table.html new file mode 100644 index 0000000..bba31e2 --- /dev/null +++ b/src/apps/files/markdown_to_table.html @@ -0,0 +1,204 @@ + + + + + + Markdown Tables + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/evm_get_balance.html b/src/apps/finance/evm_get_balance.html new file mode 100644 index 0000000..274e21c --- /dev/null +++ b/src/apps/finance/evm_get_balance.html @@ -0,0 +1,131 @@ + + + + + + Wallet Balance + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/evm_get_block.html b/src/apps/finance/evm_get_block.html new file mode 100644 index 0000000..42c022a --- /dev/null +++ b/src/apps/finance/evm_get_block.html @@ -0,0 +1,150 @@ + + + + + + Block Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/evm_get_gas_price.html b/src/apps/finance/evm_get_gas_price.html new file mode 100644 index 0000000..cf8530f --- /dev/null +++ b/src/apps/finance/evm_get_gas_price.html @@ -0,0 +1,126 @@ + + + + + + Gas Price + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/evm_get_transaction.html b/src/apps/finance/evm_get_transaction.html new file mode 100644 index 0000000..aa0981e --- /dev/null +++ b/src/apps/finance/evm_get_transaction.html @@ -0,0 +1,169 @@ + + + + + + Transaction Details + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/financial_datasets_get_financials.html b/src/apps/finance/financial_datasets_get_financials.html new file mode 100644 index 0000000..cbb3aa4 --- /dev/null +++ b/src/apps/finance/financial_datasets_get_financials.html @@ -0,0 +1,158 @@ + + + + + + Financial Statements + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/financial_datasets_get_insider_trades.html b/src/apps/finance/financial_datasets_get_insider_trades.html new file mode 100644 index 0000000..eae642d --- /dev/null +++ b/src/apps/finance/financial_datasets_get_insider_trades.html @@ -0,0 +1,202 @@ + + + + + + Insider Trades + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/financial_datasets_get_prices.html b/src/apps/finance/financial_datasets_get_prices.html new file mode 100644 index 0000000..76baf13 --- /dev/null +++ b/src/apps/finance/financial_datasets_get_prices.html @@ -0,0 +1,154 @@ + + + + + + Price Data + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/openbb_get_market_news.html b/src/apps/finance/openbb_get_market_news.html new file mode 100644 index 0000000..d8028ed --- /dev/null +++ b/src/apps/finance/openbb_get_market_news.html @@ -0,0 +1,179 @@ + + + + + + Market News + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/openbb_get_stock_data.html b/src/apps/finance/openbb_get_stock_data.html new file mode 100644 index 0000000..4cc76ee --- /dev/null +++ b/src/apps/finance/openbb_get_stock_data.html @@ -0,0 +1,154 @@ + + + + + + Stock Quotes + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/openbb_search_stocks.html b/src/apps/finance/openbb_search_stocks.html new file mode 100644 index 0000000..d3de4ef --- /dev/null +++ b/src/apps/finance/openbb_search_stocks.html @@ -0,0 +1,128 @@ + + + + + + Stock Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/yfinance_get_dividends.html b/src/apps/finance/yfinance_get_dividends.html new file mode 100644 index 0000000..85d350a --- /dev/null +++ b/src/apps/finance/yfinance_get_dividends.html @@ -0,0 +1,146 @@ + + + + + + Dividend History + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/yfinance_get_historical_data.html b/src/apps/finance/yfinance_get_historical_data.html new file mode 100644 index 0000000..26faa15 --- /dev/null +++ b/src/apps/finance/yfinance_get_historical_data.html @@ -0,0 +1,155 @@ + + + + + + Historical Price Data + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/yfinance_get_options.html b/src/apps/finance/yfinance_get_options.html new file mode 100644 index 0000000..d1d88a8 --- /dev/null +++ b/src/apps/finance/yfinance_get_options.html @@ -0,0 +1,192 @@ + + + + + + Options Chain + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/yfinance_get_stock_info.html b/src/apps/finance/yfinance_get_stock_info.html new file mode 100644 index 0000000..62cb684 --- /dev/null +++ b/src/apps/finance/yfinance_get_stock_info.html @@ -0,0 +1,181 @@ + + + + + + Stock Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/finance/yfinance_get_stock_price.html b/src/apps/finance/yfinance_get_stock_price.html new file mode 100644 index 0000000..87301a6 --- /dev/null +++ b/src/apps/finance/yfinance_get_stock_price.html @@ -0,0 +1,170 @@ + + + + + + Stock Price + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_bigquery_list_datasets.html b/src/apps/google/google_bigquery_list_datasets.html new file mode 100644 index 0000000..54942ee --- /dev/null +++ b/src/apps/google/google_bigquery_list_datasets.html @@ -0,0 +1,138 @@ + + + + + + BigQuery Datasets + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_bigquery_list_tables.html b/src/apps/google/google_bigquery_list_tables.html new file mode 100644 index 0000000..14b0002 --- /dev/null +++ b/src/apps/google/google_bigquery_list_tables.html @@ -0,0 +1,161 @@ + + + + + + BigQuery Tables + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_bigquery_query.html b/src/apps/google/google_bigquery_query.html new file mode 100644 index 0000000..786ea4a --- /dev/null +++ b/src/apps/google/google_bigquery_query.html @@ -0,0 +1,158 @@ + + + + + + BigQuery Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_maps_directions.html b/src/apps/google/google_maps_directions.html new file mode 100644 index 0000000..c1e09fe --- /dev/null +++ b/src/apps/google/google_maps_directions.html @@ -0,0 +1,175 @@ + + + + + + Directions + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_maps_geocode.html b/src/apps/google/google_maps_geocode.html new file mode 100644 index 0000000..5a26e3f --- /dev/null +++ b/src/apps/google/google_maps_geocode.html @@ -0,0 +1,148 @@ + + + + + + Geocode Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_maps_reverse_geocode.html b/src/apps/google/google_maps_reverse_geocode.html new file mode 100644 index 0000000..9820102 --- /dev/null +++ b/src/apps/google/google_maps_reverse_geocode.html @@ -0,0 +1,142 @@ + + + + + + Reverse Geocode Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/google/google_maps_search_places.html b/src/apps/google/google_maps_search_places.html new file mode 100644 index 0000000..871f7b3 --- /dev/null +++ b/src/apps/google/google_maps_search_places.html @@ -0,0 +1,152 @@ + + + + + + Places Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/image/extract_text_from_image.html b/src/apps/image/extract_text_from_image.html new file mode 100644 index 0000000..792da33 --- /dev/null +++ b/src/apps/image/extract_text_from_image.html @@ -0,0 +1,148 @@ + + + + + + Extracted Text (OCR) + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/local/execute_shell_command.html b/src/apps/local/execute_shell_command.html new file mode 100644 index 0000000..41c3fde --- /dev/null +++ b/src/apps/local/execute_shell_command.html @@ -0,0 +1,157 @@ + + + + + + Shell Command Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/dalle_generate_image.html b/src/apps/media/dalle_generate_image.html new file mode 100644 index 0000000..f1f925b --- /dev/null +++ b/src/apps/media/dalle_generate_image.html @@ -0,0 +1,157 @@ + + + + + + DALL-E Generated Image + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/fal_run_model.html b/src/apps/media/fal_run_model.html new file mode 100644 index 0000000..dcc0239 --- /dev/null +++ b/src/apps/media/fal_run_model.html @@ -0,0 +1,168 @@ + + + + + + Fal Model Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/giphy_random.html b/src/apps/media/giphy_random.html new file mode 100644 index 0000000..67b6dda --- /dev/null +++ b/src/apps/media/giphy_random.html @@ -0,0 +1,150 @@ + + + + + + Giphy Random GIF + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/giphy_search.html b/src/apps/media/giphy_search.html new file mode 100644 index 0000000..4f241a4 --- /dev/null +++ b/src/apps/media/giphy_search.html @@ -0,0 +1,150 @@ + + + + + + Giphy Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/giphy_trending.html b/src/apps/media/giphy_trending.html new file mode 100644 index 0000000..b66730d --- /dev/null +++ b/src/apps/media/giphy_trending.html @@ -0,0 +1,147 @@ + + + + + + Giphy Trending + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/lumalab_generate_video.html b/src/apps/media/lumalab_generate_video.html new file mode 100644 index 0000000..3f7e312 --- /dev/null +++ b/src/apps/media/lumalab_generate_video.html @@ -0,0 +1,148 @@ + + + + + + LumaLab Video Generation + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/models_labs_generate_image.html b/src/apps/media/models_labs_generate_image.html new file mode 100644 index 0000000..3b686d3 --- /dev/null +++ b/src/apps/media/models_labs_generate_image.html @@ -0,0 +1,168 @@ + + + + + + ModelsLab Generated Image + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/moviepy_create_video.html b/src/apps/media/moviepy_create_video.html new file mode 100644 index 0000000..eeef72e --- /dev/null +++ b/src/apps/media/moviepy_create_video.html @@ -0,0 +1,124 @@ + + + + + + MoviePy Video Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/moviepy_trim_video.html b/src/apps/media/moviepy_trim_video.html new file mode 100644 index 0000000..c154ca4 --- /dev/null +++ b/src/apps/media/moviepy_trim_video.html @@ -0,0 +1,131 @@ + + + + + + MoviePy Video Trimmed + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/nano_banana_run.html b/src/apps/media/nano_banana_run.html new file mode 100644 index 0000000..627e754 --- /dev/null +++ b/src/apps/media/nano_banana_run.html @@ -0,0 +1,172 @@ + + + + + + NanoBanana Model Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/opencv_convert_format.html b/src/apps/media/opencv_convert_format.html new file mode 100644 index 0000000..19852fe --- /dev/null +++ b/src/apps/media/opencv_convert_format.html @@ -0,0 +1,124 @@ + + + + + + OpenCV Convert Format + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/opencv_crop_image.html b/src/apps/media/opencv_crop_image.html new file mode 100644 index 0000000..e796cda --- /dev/null +++ b/src/apps/media/opencv_crop_image.html @@ -0,0 +1,119 @@ + + + + + + OpenCV Crop Image + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/opencv_resize_image.html b/src/apps/media/opencv_resize_image.html new file mode 100644 index 0000000..5757b67 --- /dev/null +++ b/src/apps/media/opencv_resize_image.html @@ -0,0 +1,122 @@ + + + + + + OpenCV Resize Image + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/opencv_rotate_image.html b/src/apps/media/opencv_rotate_image.html new file mode 100644 index 0000000..d2dff97 --- /dev/null +++ b/src/apps/media/opencv_rotate_image.html @@ -0,0 +1,119 @@ + + + + + + OpenCV Rotate Image + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/replicate_get_prediction.html b/src/apps/media/replicate_get_prediction.html new file mode 100644 index 0000000..09447fe --- /dev/null +++ b/src/apps/media/replicate_get_prediction.html @@ -0,0 +1,179 @@ + + + + + + Replicate Prediction Status + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/replicate_run_model.html b/src/apps/media/replicate_run_model.html new file mode 100644 index 0000000..e363967 --- /dev/null +++ b/src/apps/media/replicate_run_model.html @@ -0,0 +1,168 @@ + + + + + + Replicate Model Output + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/replicate_search_models.html b/src/apps/media/replicate_search_models.html new file mode 100644 index 0000000..50b564f --- /dev/null +++ b/src/apps/media/replicate_search_models.html @@ -0,0 +1,158 @@ + + + + + + Replicate Model Search + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/unsplash_get_photo.html b/src/apps/media/unsplash_get_photo.html new file mode 100644 index 0000000..ed09c0f --- /dev/null +++ b/src/apps/media/unsplash_get_photo.html @@ -0,0 +1,158 @@ + + + + + + Unsplash Photo Details + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/unsplash_get_random_photo.html b/src/apps/media/unsplash_get_random_photo.html new file mode 100644 index 0000000..222e495 --- /dev/null +++ b/src/apps/media/unsplash_get_random_photo.html @@ -0,0 +1,186 @@ + + + + + + Unsplash Random Photo + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/media/unsplash_search_photos.html b/src/apps/media/unsplash_search_photos.html new file mode 100644 index 0000000..49f6bf9 --- /dev/null +++ b/src/apps/media/unsplash_search_photos.html @@ -0,0 +1,168 @@ + + + + + + Unsplash Search Photos + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/mem0_add_memory.html b/src/apps/memory/mem0_add_memory.html new file mode 100644 index 0000000..9c8f67f --- /dev/null +++ b/src/apps/memory/mem0_add_memory.html @@ -0,0 +1,168 @@ + + + + + + Add Memory Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/mem0_get_memories.html b/src/apps/memory/mem0_get_memories.html new file mode 100644 index 0000000..8f2cb97 --- /dev/null +++ b/src/apps/memory/mem0_get_memories.html @@ -0,0 +1,160 @@ + + + + + + User Memories + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/mem0_search_memory.html b/src/apps/memory/mem0_search_memory.html new file mode 100644 index 0000000..127889c --- /dev/null +++ b/src/apps/memory/mem0_search_memory.html @@ -0,0 +1,162 @@ + + + + + + Memory Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/zep_add_memory.html b/src/apps/memory/zep_add_memory.html new file mode 100644 index 0000000..05f37fb --- /dev/null +++ b/src/apps/memory/zep_add_memory.html @@ -0,0 +1,197 @@ + + + + + + Zep Add Memory Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/zep_get_session.html b/src/apps/memory/zep_get_session.html new file mode 100644 index 0000000..d2a5f1f --- /dev/null +++ b/src/apps/memory/zep_get_session.html @@ -0,0 +1,183 @@ + + + + + + Zep Session Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/memory/zep_search_memory.html b/src/apps/memory/zep_search_memory.html new file mode 100644 index 0000000..9ff8e0a --- /dev/null +++ b/src/apps/memory/zep_search_memory.html @@ -0,0 +1,182 @@ + + + + + + Zep Memory Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/discord_add_reaction.html b/src/apps/messaging/discord_add_reaction.html new file mode 100644 index 0000000..dedf11a --- /dev/null +++ b/src/apps/messaging/discord_add_reaction.html @@ -0,0 +1,117 @@ + + + + + + Discord Reaction Added + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/discord_get_messages.html b/src/apps/messaging/discord_get_messages.html new file mode 100644 index 0000000..7c2eae5 --- /dev/null +++ b/src/apps/messaging/discord_get_messages.html @@ -0,0 +1,128 @@ + + + + + + Discord Messages + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/discord_list_channels.html b/src/apps/messaging/discord_list_channels.html new file mode 100644 index 0000000..0b16b47 --- /dev/null +++ b/src/apps/messaging/discord_list_channels.html @@ -0,0 +1,159 @@ + + + + + + Discord Channels + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/discord_send_message.html b/src/apps/messaging/discord_send_message.html new file mode 100644 index 0000000..4d8df11 --- /dev/null +++ b/src/apps/messaging/discord_send_message.html @@ -0,0 +1,149 @@ + + + + + + Discord Message Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/resend_send_email.html b/src/apps/messaging/resend_send_email.html new file mode 100644 index 0000000..4ed10f1 --- /dev/null +++ b/src/apps/messaging/resend_send_email.html @@ -0,0 +1,149 @@ + + + + + + Email Sent via Resend + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/send_email.html b/src/apps/messaging/send_email.html new file mode 100644 index 0000000..2bc4504 --- /dev/null +++ b/src/apps/messaging/send_email.html @@ -0,0 +1,149 @@ + + + + + + Email Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_add_reaction.html b/src/apps/messaging/slack_add_reaction.html new file mode 100644 index 0000000..7441cfc --- /dev/null +++ b/src/apps/messaging/slack_add_reaction.html @@ -0,0 +1,117 @@ + + + + + + Slack Reaction Added + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_get_channel_history.html b/src/apps/messaging/slack_get_channel_history.html new file mode 100644 index 0000000..8313867 --- /dev/null +++ b/src/apps/messaging/slack_get_channel_history.html @@ -0,0 +1,155 @@ + + + + + + Channel History + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_get_thread_replies.html b/src/apps/messaging/slack_get_thread_replies.html new file mode 100644 index 0000000..c424849 --- /dev/null +++ b/src/apps/messaging/slack_get_thread_replies.html @@ -0,0 +1,142 @@ + + + + + + Slack Thread Replies + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_get_user_info.html b/src/apps/messaging/slack_get_user_info.html new file mode 100644 index 0000000..c20e456 --- /dev/null +++ b/src/apps/messaging/slack_get_user_info.html @@ -0,0 +1,123 @@ + + + + + + Slack User Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_list_channels.html b/src/apps/messaging/slack_list_channels.html new file mode 100644 index 0000000..b910489 --- /dev/null +++ b/src/apps/messaging/slack_list_channels.html @@ -0,0 +1,159 @@ + + + + + + Slack Channels + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_search_messages.html b/src/apps/messaging/slack_search_messages.html new file mode 100644 index 0000000..3fda231 --- /dev/null +++ b/src/apps/messaging/slack_search_messages.html @@ -0,0 +1,178 @@ + + + + + + Search Messages + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/slack_send_message.html b/src/apps/messaging/slack_send_message.html new file mode 100644 index 0000000..0ac2b48 --- /dev/null +++ b/src/apps/messaging/slack_send_message.html @@ -0,0 +1,154 @@ + + + + + + Slack Message Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/telegram_edit_message.html b/src/apps/messaging/telegram_edit_message.html new file mode 100644 index 0000000..ba654bd --- /dev/null +++ b/src/apps/messaging/telegram_edit_message.html @@ -0,0 +1,126 @@ + + + + + + Telegram Message Edited + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/telegram_get_updates.html b/src/apps/messaging/telegram_get_updates.html new file mode 100644 index 0000000..deb03d1 --- /dev/null +++ b/src/apps/messaging/telegram_get_updates.html @@ -0,0 +1,178 @@ + + + + + + Telegram Updates + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/telegram_send_message.html b/src/apps/messaging/telegram_send_message.html new file mode 100644 index 0000000..ce5ab96 --- /dev/null +++ b/src/apps/messaging/telegram_send_message.html @@ -0,0 +1,149 @@ + + + + + + Telegram Message Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/telegram_send_photo.html b/src/apps/messaging/telegram_send_photo.html new file mode 100644 index 0000000..5759220 --- /dev/null +++ b/src/apps/messaging/telegram_send_photo.html @@ -0,0 +1,124 @@ + + + + + + Telegram Photo Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/twilio_get_sms_status.html b/src/apps/messaging/twilio_get_sms_status.html new file mode 100644 index 0000000..8f5a1a7 --- /dev/null +++ b/src/apps/messaging/twilio_get_sms_status.html @@ -0,0 +1,145 @@ + + + + + + Twilio SMS Status + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/twilio_make_call.html b/src/apps/messaging/twilio_make_call.html new file mode 100644 index 0000000..e186fcf --- /dev/null +++ b/src/apps/messaging/twilio_make_call.html @@ -0,0 +1,118 @@ + + + + + + Twilio Voice Call + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/twilio_send_sms.html b/src/apps/messaging/twilio_send_sms.html new file mode 100644 index 0000000..7076259 --- /dev/null +++ b/src/apps/messaging/twilio_send_sms.html @@ -0,0 +1,152 @@ + + + + + + SMS Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/webex_create_room.html b/src/apps/messaging/webex_create_room.html new file mode 100644 index 0000000..0c43c2e --- /dev/null +++ b/src/apps/messaging/webex_create_room.html @@ -0,0 +1,119 @@ + + + + + + Webex Room Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/webex_list_rooms.html b/src/apps/messaging/webex_list_rooms.html new file mode 100644 index 0000000..fb418a2 --- /dev/null +++ b/src/apps/messaging/webex_list_rooms.html @@ -0,0 +1,159 @@ + + + + + + Webex Rooms + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/webex_send_message.html b/src/apps/messaging/webex_send_message.html new file mode 100644 index 0000000..120c01c --- /dev/null +++ b/src/apps/messaging/webex_send_message.html @@ -0,0 +1,149 @@ + + + + + + Webex Message Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/messaging/whatsapp_send_message.html b/src/apps/messaging/whatsapp_send_message.html new file mode 100644 index 0000000..af20a4b --- /dev/null +++ b/src/apps/messaging/whatsapp_send_message.html @@ -0,0 +1,149 @@ + + + + + + WhatsApp Message Sent + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/bitbucket_create_pr.html b/src/apps/project_management/bitbucket_create_pr.html new file mode 100644 index 0000000..619384f --- /dev/null +++ b/src/apps/project_management/bitbucket_create_pr.html @@ -0,0 +1,159 @@ + + + + + + Bitbucket Pull Request Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/bitbucket_get_repo.html b/src/apps/project_management/bitbucket_get_repo.html new file mode 100644 index 0000000..9c3fc8e --- /dev/null +++ b/src/apps/project_management/bitbucket_get_repo.html @@ -0,0 +1,149 @@ + + + + + + Bitbucket Repository + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/bitbucket_list_repos.html b/src/apps/project_management/bitbucket_list_repos.html new file mode 100644 index 0000000..251d45d --- /dev/null +++ b/src/apps/project_management/bitbucket_list_repos.html @@ -0,0 +1,165 @@ + + + + + + Bitbucket Repositories + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/clickup_create_task.html b/src/apps/project_management/clickup_create_task.html new file mode 100644 index 0000000..e5d0dcd --- /dev/null +++ b/src/apps/project_management/clickup_create_task.html @@ -0,0 +1,158 @@ + + + + + + ClickUp Task Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/clickup_get_task.html b/src/apps/project_management/clickup_get_task.html new file mode 100644 index 0000000..ffaaebd --- /dev/null +++ b/src/apps/project_management/clickup_get_task.html @@ -0,0 +1,155 @@ + + + + + + ClickUp Task + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/confluence_create_page.html b/src/apps/project_management/confluence_create_page.html new file mode 100644 index 0000000..848a70d --- /dev/null +++ b/src/apps/project_management/confluence_create_page.html @@ -0,0 +1,157 @@ + + + + + + Confluence Page Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/confluence_get_page.html b/src/apps/project_management/confluence_get_page.html new file mode 100644 index 0000000..282006b --- /dev/null +++ b/src/apps/project_management/confluence_get_page.html @@ -0,0 +1,154 @@ + + + + + + Confluence Page + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/confluence_search.html b/src/apps/project_management/confluence_search.html new file mode 100644 index 0000000..596c08c --- /dev/null +++ b/src/apps/project_management/confluence_search.html @@ -0,0 +1,168 @@ + + + + + + Confluence Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_create_issue.html b/src/apps/project_management/github_create_issue.html new file mode 100644 index 0000000..5beed74 --- /dev/null +++ b/src/apps/project_management/github_create_issue.html @@ -0,0 +1,168 @@ + + + + + + GitHub Issue Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_get_repo.html b/src/apps/project_management/github_get_repo.html new file mode 100644 index 0000000..dbfd366 --- /dev/null +++ b/src/apps/project_management/github_get_repo.html @@ -0,0 +1,161 @@ + + + + + + GitHub Repository + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_list_commits.html b/src/apps/project_management/github_list_commits.html new file mode 100644 index 0000000..2d3f5d8 --- /dev/null +++ b/src/apps/project_management/github_list_commits.html @@ -0,0 +1,167 @@ + + + + + + GitHub Commits + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_list_issues.html b/src/apps/project_management/github_list_issues.html new file mode 100644 index 0000000..d922b97 --- /dev/null +++ b/src/apps/project_management/github_list_issues.html @@ -0,0 +1,182 @@ + + + + + + GitHub Issues + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_list_pull_requests.html b/src/apps/project_management/github_list_pull_requests.html new file mode 100644 index 0000000..ce8288d --- /dev/null +++ b/src/apps/project_management/github_list_pull_requests.html @@ -0,0 +1,181 @@ + + + + + + GitHub Pull Requests + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/github_list_releases.html b/src/apps/project_management/github_list_releases.html new file mode 100644 index 0000000..73dd618 --- /dev/null +++ b/src/apps/project_management/github_list_releases.html @@ -0,0 +1,174 @@ + + + + + + GitHub Releases + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/jira_create_issue.html b/src/apps/project_management/jira_create_issue.html new file mode 100644 index 0000000..3fb1216 --- /dev/null +++ b/src/apps/project_management/jira_create_issue.html @@ -0,0 +1,172 @@ + + + + + + Jira Issue Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/jira_get_issue.html b/src/apps/project_management/jira_get_issue.html new file mode 100644 index 0000000..c7084b6 --- /dev/null +++ b/src/apps/project_management/jira_get_issue.html @@ -0,0 +1,176 @@ + + + + + + Jira Issue + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/jira_search_issues.html b/src/apps/project_management/jira_search_issues.html new file mode 100644 index 0000000..68c14b0 --- /dev/null +++ b/src/apps/project_management/jira_search_issues.html @@ -0,0 +1,177 @@ + + + + + + Jira Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/linear_create_issue.html b/src/apps/project_management/linear_create_issue.html new file mode 100644 index 0000000..c6dcf43 --- /dev/null +++ b/src/apps/project_management/linear_create_issue.html @@ -0,0 +1,167 @@ + + + + + + Linear Issue Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/linear_get_issue.html b/src/apps/project_management/linear_get_issue.html new file mode 100644 index 0000000..a8f0d63 --- /dev/null +++ b/src/apps/project_management/linear_get_issue.html @@ -0,0 +1,164 @@ + + + + + + Linear Issue + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/linear_list_issues.html b/src/apps/project_management/linear_list_issues.html new file mode 100644 index 0000000..418ed3e --- /dev/null +++ b/src/apps/project_management/linear_list_issues.html @@ -0,0 +1,176 @@ + + + + + + Linear Issues + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/notion_create_page.html b/src/apps/project_management/notion_create_page.html new file mode 100644 index 0000000..b256533 --- /dev/null +++ b/src/apps/project_management/notion_create_page.html @@ -0,0 +1,150 @@ + + + + + + Notion Page Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/notion_get_page.html b/src/apps/project_management/notion_get_page.html new file mode 100644 index 0000000..81ec8cc --- /dev/null +++ b/src/apps/project_management/notion_get_page.html @@ -0,0 +1,147 @@ + + + + + + Notion Page + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/notion_search.html b/src/apps/project_management/notion_search.html new file mode 100644 index 0000000..6b409b9 --- /dev/null +++ b/src/apps/project_management/notion_search.html @@ -0,0 +1,153 @@ + + + + + + Notion Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/todoist_create_task.html b/src/apps/project_management/todoist_create_task.html new file mode 100644 index 0000000..41d56b9 --- /dev/null +++ b/src/apps/project_management/todoist_create_task.html @@ -0,0 +1,180 @@ + + + + + + Todoist Task Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/todoist_get_tasks.html b/src/apps/project_management/todoist_get_tasks.html new file mode 100644 index 0000000..35ef0e1 --- /dev/null +++ b/src/apps/project_management/todoist_get_tasks.html @@ -0,0 +1,171 @@ + + + + + + Todoist Tasks + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/trello_create_card.html b/src/apps/project_management/trello_create_card.html new file mode 100644 index 0000000..cd964f4 --- /dev/null +++ b/src/apps/project_management/trello_create_card.html @@ -0,0 +1,150 @@ + + + + + + Trello Card Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/trello_get_boards.html b/src/apps/project_management/trello_get_boards.html new file mode 100644 index 0000000..8533b6b --- /dev/null +++ b/src/apps/project_management/trello_get_boards.html @@ -0,0 +1,164 @@ + + + + + + Trello Boards + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/zendesk_create_ticket.html b/src/apps/project_management/zendesk_create_ticket.html new file mode 100644 index 0000000..fa9e8a1 --- /dev/null +++ b/src/apps/project_management/zendesk_create_ticket.html @@ -0,0 +1,175 @@ + + + + + + Zendesk Ticket Created + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/zendesk_get_ticket.html b/src/apps/project_management/zendesk_get_ticket.html new file mode 100644 index 0000000..4bee399 --- /dev/null +++ b/src/apps/project_management/zendesk_get_ticket.html @@ -0,0 +1,182 @@ + + + + + + Zendesk Ticket + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/project_management/zendesk_search_tickets.html b/src/apps/project_management/zendesk_search_tickets.html new file mode 100644 index 0000000..48aa55b --- /dev/null +++ b/src/apps/project_management/zendesk_search_tickets.html @@ -0,0 +1,188 @@ + + + + + + Zendesk Ticket Search + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/arxiv_read_paper.html b/src/apps/research/arxiv_read_paper.html new file mode 100644 index 0000000..3e8507d --- /dev/null +++ b/src/apps/research/arxiv_read_paper.html @@ -0,0 +1,212 @@ + + + + + + ArXiv Paper Reader + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/arxiv_search.html b/src/apps/research/arxiv_search.html new file mode 100644 index 0000000..18e07c8 --- /dev/null +++ b/src/apps/research/arxiv_search.html @@ -0,0 +1,181 @@ + + + + + + ArXiv Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/pubmed_get_article.html b/src/apps/research/pubmed_get_article.html new file mode 100644 index 0000000..f057f17 --- /dev/null +++ b/src/apps/research/pubmed_get_article.html @@ -0,0 +1,267 @@ + + + + + + PubMed Article Detail + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/pubmed_search.html b/src/apps/research/pubmed_search.html new file mode 100644 index 0000000..54cc6bd --- /dev/null +++ b/src/apps/research/pubmed_search.html @@ -0,0 +1,194 @@ + + + + + + PubMed Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/wikipedia_get_page.html b/src/apps/research/wikipedia_get_page.html new file mode 100644 index 0000000..f234ff3 --- /dev/null +++ b/src/apps/research/wikipedia_get_page.html @@ -0,0 +1,172 @@ + + + + + + Wikipedia Page + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/research/wikipedia_search.html b/src/apps/research/wikipedia_search.html new file mode 100644 index 0000000..161015b --- /dev/null +++ b/src/apps/research/wikipedia_search.html @@ -0,0 +1,144 @@ + + + + + + Wikipedia Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/baidu_search.html b/src/apps/search/baidu_search.html new file mode 100644 index 0000000..a77c29d --- /dev/null +++ b/src/apps/search/baidu_search.html @@ -0,0 +1,145 @@ + + + + + + Baidu Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/brave_image_search.html b/src/apps/search/brave_image_search.html new file mode 100644 index 0000000..a844b2c --- /dev/null +++ b/src/apps/search/brave_image_search.html @@ -0,0 +1,86 @@ + + + + + + Brave Image Search + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/brave_news_search.html b/src/apps/search/brave_news_search.html new file mode 100644 index 0000000..030d741 --- /dev/null +++ b/src/apps/search/brave_news_search.html @@ -0,0 +1,122 @@ + + + + + + Brave News Search + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/brave_web_search.html b/src/apps/search/brave_web_search.html new file mode 100644 index 0000000..02d7831 --- /dev/null +++ b/src/apps/search/brave_web_search.html @@ -0,0 +1,145 @@ + + + + + + Brave Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/duckduckgo_images.html b/src/apps/search/duckduckgo_images.html new file mode 100644 index 0000000..4b17f6a --- /dev/null +++ b/src/apps/search/duckduckgo_images.html @@ -0,0 +1,77 @@ + + + + + + DuckDuckGo Image Search + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/duckduckgo_news.html b/src/apps/search/duckduckgo_news.html new file mode 100644 index 0000000..a542bc9 --- /dev/null +++ b/src/apps/search/duckduckgo_news.html @@ -0,0 +1,140 @@ + + + + + + DuckDuckGo News Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/duckduckgo_search.html b/src/apps/search/duckduckgo_search.html new file mode 100644 index 0000000..135ce43 --- /dev/null +++ b/src/apps/search/duckduckgo_search.html @@ -0,0 +1,145 @@ + + + + + + DuckDuckGo Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/exa_find_similar.html b/src/apps/search/exa_find_similar.html new file mode 100644 index 0000000..2409bae --- /dev/null +++ b/src/apps/search/exa_find_similar.html @@ -0,0 +1,92 @@ + + + + + + Exa Find Similar + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/exa_search.html b/src/apps/search/exa_search.html new file mode 100644 index 0000000..1207ff0 --- /dev/null +++ b/src/apps/search/exa_search.html @@ -0,0 +1,164 @@ + + + + + + Exa Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/searxng_search.html b/src/apps/search/searxng_search.html new file mode 100644 index 0000000..4202724 --- /dev/null +++ b/src/apps/search/searxng_search.html @@ -0,0 +1,145 @@ + + + + + + SearXNG Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/seltz_search.html b/src/apps/search/seltz_search.html new file mode 100644 index 0000000..ef05b54 --- /dev/null +++ b/src/apps/search/seltz_search.html @@ -0,0 +1,151 @@ + + + + + + Seltz Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/serpapi_search.html b/src/apps/search/serpapi_search.html new file mode 100644 index 0000000..c92e888 --- /dev/null +++ b/src/apps/search/serpapi_search.html @@ -0,0 +1,210 @@ + + + + + + SerpAPI Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/serper_google_search.html b/src/apps/search/serper_google_search.html new file mode 100644 index 0000000..7811aab --- /dev/null +++ b/src/apps/search/serper_google_search.html @@ -0,0 +1,145 @@ + + + + + + Serper Google Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/serper_image_search.html b/src/apps/search/serper_image_search.html new file mode 100644 index 0000000..323a6f2 --- /dev/null +++ b/src/apps/search/serper_image_search.html @@ -0,0 +1,76 @@ + + + + + + Serper Image Search + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/serper_news_search.html b/src/apps/search/serper_news_search.html new file mode 100644 index 0000000..692dfbb --- /dev/null +++ b/src/apps/search/serper_news_search.html @@ -0,0 +1,78 @@ + + + + + + Serper News Search + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/tavily_extract.html b/src/apps/search/tavily_extract.html new file mode 100644 index 0000000..2f19344 --- /dev/null +++ b/src/apps/search/tavily_extract.html @@ -0,0 +1,83 @@ + + + + + + Tavily Extract + + + + +
+
Waiting for data...
+ + +
+ + + diff --git a/src/apps/search/tavily_web_search.html b/src/apps/search/tavily_web_search.html new file mode 100644 index 0000000..8feef77 --- /dev/null +++ b/src/apps/search/tavily_web_search.html @@ -0,0 +1,149 @@ + + + + + + Web Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/search/valyu_search.html b/src/apps/search/valyu_search.html new file mode 100644 index 0000000..1ab1c9b --- /dev/null +++ b/src/apps/search/valyu_search.html @@ -0,0 +1,162 @@ + + + + + + Valyu Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_get_comments.html b/src/apps/social/hackernews_get_comments.html new file mode 100644 index 0000000..2db9b3b --- /dev/null +++ b/src/apps/social/hackernews_get_comments.html @@ -0,0 +1,134 @@ + + + + + + Hacker News Comments + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_get_story.html b/src/apps/social/hackernews_get_story.html new file mode 100644 index 0000000..ff624f8 --- /dev/null +++ b/src/apps/social/hackernews_get_story.html @@ -0,0 +1,202 @@ + + + + + + Hacker News Story + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_get_top_stories.html b/src/apps/social/hackernews_get_top_stories.html new file mode 100644 index 0000000..3714d58 --- /dev/null +++ b/src/apps/social/hackernews_get_top_stories.html @@ -0,0 +1,193 @@ + + + + + + Hacker News Top Stories + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_get_user.html b/src/apps/social/hackernews_get_user.html new file mode 100644 index 0000000..aee0d74 --- /dev/null +++ b/src/apps/social/hackernews_get_user.html @@ -0,0 +1,146 @@ + + + + + + Hacker News User Profile + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_search.html b/src/apps/social/hackernews_search.html new file mode 100644 index 0000000..2e4abd2 --- /dev/null +++ b/src/apps/social/hackernews_search.html @@ -0,0 +1,203 @@ + + + + + + Hacker News Search + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/hackernews_search_by_date.html b/src/apps/social/hackernews_search_by_date.html new file mode 100644 index 0000000..650a1db --- /dev/null +++ b/src/apps/social/hackernews_search_by_date.html @@ -0,0 +1,152 @@ + + + + + + Hacker News Search by Date + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_get_comments.html b/src/apps/social/reddit_get_comments.html new file mode 100644 index 0000000..fa64e65 --- /dev/null +++ b/src/apps/social/reddit_get_comments.html @@ -0,0 +1,116 @@ + + + + + + Reddit Comments + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_get_hot_posts.html b/src/apps/social/reddit_get_hot_posts.html new file mode 100644 index 0000000..2e991e4 --- /dev/null +++ b/src/apps/social/reddit_get_hot_posts.html @@ -0,0 +1,139 @@ + + + + + + Reddit Hot Posts + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_get_post.html b/src/apps/social/reddit_get_post.html new file mode 100644 index 0000000..84141ee --- /dev/null +++ b/src/apps/social/reddit_get_post.html @@ -0,0 +1,197 @@ + + + + + + Reddit Post Detail + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_get_subreddit_info.html b/src/apps/social/reddit_get_subreddit_info.html new file mode 100644 index 0000000..a1c5143 --- /dev/null +++ b/src/apps/social/reddit_get_subreddit_info.html @@ -0,0 +1,155 @@ + + + + + + Reddit Subreddit Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_get_top_posts.html b/src/apps/social/reddit_get_top_posts.html new file mode 100644 index 0000000..2255b1d --- /dev/null +++ b/src/apps/social/reddit_get_top_posts.html @@ -0,0 +1,158 @@ + + + + + + Reddit Top Posts + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/reddit_search_posts.html b/src/apps/social/reddit_search_posts.html new file mode 100644 index 0000000..cd6573a --- /dev/null +++ b/src/apps/social/reddit_search_posts.html @@ -0,0 +1,186 @@ + + + + + + Reddit Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/x_get_user.html b/src/apps/social/x_get_user.html new file mode 100644 index 0000000..cb8cc71 --- /dev/null +++ b/src/apps/social/x_get_user.html @@ -0,0 +1,167 @@ + + + + + + X User Profile + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/x_post_tweet.html b/src/apps/social/x_post_tweet.html new file mode 100644 index 0000000..886b15a --- /dev/null +++ b/src/apps/social/x_post_tweet.html @@ -0,0 +1,147 @@ + + + + + + Tweet Posted + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/x_search_tweets.html b/src/apps/social/x_search_tweets.html new file mode 100644 index 0000000..3e30087 --- /dev/null +++ b/src/apps/social/x_search_tweets.html @@ -0,0 +1,181 @@ + + + + + + X Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/youtube_get_captions.html b/src/apps/social/youtube_get_captions.html new file mode 100644 index 0000000..580058b --- /dev/null +++ b/src/apps/social/youtube_get_captions.html @@ -0,0 +1,167 @@ + + + + + + YouTube Captions + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/youtube_get_video_info.html b/src/apps/social/youtube_get_video_info.html new file mode 100644 index 0000000..58ebe11 --- /dev/null +++ b/src/apps/social/youtube_get_video_info.html @@ -0,0 +1,222 @@ + + + + + + YouTube Video Info + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/social/youtube_search.html b/src/apps/social/youtube_search.html new file mode 100644 index 0000000..da9cd4d --- /dev/null +++ b/src/apps/social/youtube_search.html @@ -0,0 +1,200 @@ + + + + + + YouTube Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/storage/get_object_metadata.html b/src/apps/storage/get_object_metadata.html new file mode 100644 index 0000000..e3940c8 --- /dev/null +++ b/src/apps/storage/get_object_metadata.html @@ -0,0 +1,166 @@ + + + + + + Object Metadata + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/storage/get_presigned_url.html b/src/apps/storage/get_presigned_url.html new file mode 100644 index 0000000..b973a57 --- /dev/null +++ b/src/apps/storage/get_presigned_url.html @@ -0,0 +1,138 @@ + + + + + + Presigned URL + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/storage/list_buckets.html b/src/apps/storage/list_buckets.html new file mode 100644 index 0000000..a081c83 --- /dev/null +++ b/src/apps/storage/list_buckets.html @@ -0,0 +1,140 @@ + + + + + + Storage Buckets + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/storage/list_objects.html b/src/apps/storage/list_objects.html new file mode 100644 index 0000000..cd18aa9 --- /dev/null +++ b/src/apps/storage/list_objects.html @@ -0,0 +1,179 @@ + + + + + + Storage Objects + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/weather/openweather_get_current.html b/src/apps/weather/openweather_get_current.html new file mode 100644 index 0000000..5404efa --- /dev/null +++ b/src/apps/weather/openweather_get_current.html @@ -0,0 +1,215 @@ + + + + + + Current Weather + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/weather/openweather_get_forecast.html b/src/apps/weather/openweather_get_forecast.html new file mode 100644 index 0000000..a42d2c5 --- /dev/null +++ b/src/apps/weather/openweather_get_forecast.html @@ -0,0 +1,230 @@ + + + + + + Weather Forecast + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/agentql_query.html b/src/apps/web_scraping/agentql_query.html new file mode 100644 index 0000000..5ff1b7a --- /dev/null +++ b/src/apps/web_scraping/agentql_query.html @@ -0,0 +1,128 @@ + + + + + + AgentQL Query Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/apify_run_actor.html b/src/apps/web_scraping/apify_run_actor.html new file mode 100644 index 0000000..f8e0939 --- /dev/null +++ b/src/apps/web_scraping/apify_run_actor.html @@ -0,0 +1,205 @@ + + + + + + Apify Actor Run Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/brightdata_scrape.html b/src/apps/web_scraping/brightdata_scrape.html new file mode 100644 index 0000000..d2a39f5 --- /dev/null +++ b/src/apps/web_scraping/brightdata_scrape.html @@ -0,0 +1,165 @@ + + + + + + Bright Data Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/browserbase_scrape.html b/src/apps/web_scraping/browserbase_scrape.html new file mode 100644 index 0000000..146691b --- /dev/null +++ b/src/apps/web_scraping/browserbase_scrape.html @@ -0,0 +1,165 @@ + + + + + + Browserbase Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/crawl4ai_scrape.html b/src/apps/web_scraping/crawl4ai_scrape.html new file mode 100644 index 0000000..765e804 --- /dev/null +++ b/src/apps/web_scraping/crawl4ai_scrape.html @@ -0,0 +1,165 @@ + + + + + + Crawl4AI Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/firecrawl_crawl.html b/src/apps/web_scraping/firecrawl_crawl.html new file mode 100644 index 0000000..dc59c08 --- /dev/null +++ b/src/apps/web_scraping/firecrawl_crawl.html @@ -0,0 +1,177 @@ + + + + + + Firecrawl Crawl Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/firecrawl_map.html b/src/apps/web_scraping/firecrawl_map.html new file mode 100644 index 0000000..fa5adac --- /dev/null +++ b/src/apps/web_scraping/firecrawl_map.html @@ -0,0 +1,134 @@ + + + + + + Firecrawl Map Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/firecrawl_scrape.html b/src/apps/web_scraping/firecrawl_scrape.html new file mode 100644 index 0000000..deec3b4 --- /dev/null +++ b/src/apps/web_scraping/firecrawl_scrape.html @@ -0,0 +1,165 @@ + + + + + + Firecrawl Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/firecrawl_search.html b/src/apps/web_scraping/firecrawl_search.html new file mode 100644 index 0000000..87df97c --- /dev/null +++ b/src/apps/web_scraping/firecrawl_search.html @@ -0,0 +1,151 @@ + + + + + + Firecrawl Search Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/jina_reader.html b/src/apps/web_scraping/jina_reader.html new file mode 100644 index 0000000..5a48886 --- /dev/null +++ b/src/apps/web_scraping/jina_reader.html @@ -0,0 +1,165 @@ + + + + + + Jina Reader Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/jina_search.html b/src/apps/web_scraping/jina_search.html new file mode 100644 index 0000000..54d7e91 --- /dev/null +++ b/src/apps/web_scraping/jina_search.html @@ -0,0 +1,162 @@ + + + + + + Jina Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/linkup_search.html b/src/apps/web_scraping/linkup_search.html new file mode 100644 index 0000000..bf16a05 --- /dev/null +++ b/src/apps/web_scraping/linkup_search.html @@ -0,0 +1,162 @@ + + + + + + Linkup Search Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/newspaper_extract.html b/src/apps/web_scraping/newspaper_extract.html new file mode 100644 index 0000000..2162d93 --- /dev/null +++ b/src/apps/web_scraping/newspaper_extract.html @@ -0,0 +1,165 @@ + + + + + + Newspaper Extract Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/oxylabs_scrape.html b/src/apps/web_scraping/oxylabs_scrape.html new file mode 100644 index 0000000..f9fdace --- /dev/null +++ b/src/apps/web_scraping/oxylabs_scrape.html @@ -0,0 +1,165 @@ + + + + + + Oxylabs Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/scrapegraph_markdownify.html b/src/apps/web_scraping/scrapegraph_markdownify.html new file mode 100644 index 0000000..f3a870a --- /dev/null +++ b/src/apps/web_scraping/scrapegraph_markdownify.html @@ -0,0 +1,165 @@ + + + + + + ScrapeGraph Markdownify Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/scrapegraph_scrape.html b/src/apps/web_scraping/scrapegraph_scrape.html new file mode 100644 index 0000000..0761068 --- /dev/null +++ b/src/apps/web_scraping/scrapegraph_scrape.html @@ -0,0 +1,128 @@ + + + + + + ScrapeGraph Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/scrapegraph_search.html b/src/apps/web_scraping/scrapegraph_search.html new file mode 100644 index 0000000..af3160c --- /dev/null +++ b/src/apps/web_scraping/scrapegraph_search.html @@ -0,0 +1,119 @@ + + + + + + ScrapeGraph Search Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/spider_crawl.html b/src/apps/web_scraping/spider_crawl.html new file mode 100644 index 0000000..a5a9a5d --- /dev/null +++ b/src/apps/web_scraping/spider_crawl.html @@ -0,0 +1,176 @@ + + + + + + Spider Crawl Results + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/spider_links.html b/src/apps/web_scraping/spider_links.html new file mode 100644 index 0000000..11bd30f --- /dev/null +++ b/src/apps/web_scraping/spider_links.html @@ -0,0 +1,134 @@ + + + + + + Spider Links Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/spider_scrape.html b/src/apps/web_scraping/spider_scrape.html new file mode 100644 index 0000000..c9a7d44 --- /dev/null +++ b/src/apps/web_scraping/spider_scrape.html @@ -0,0 +1,165 @@ + + + + + + Spider Scrape Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/spider_search.html b/src/apps/web_scraping/spider_search.html new file mode 100644 index 0000000..f3fc86a --- /dev/null +++ b/src/apps/web_scraping/spider_search.html @@ -0,0 +1,157 @@ + + + + + + Spider Search Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/apps/web_scraping/trafilatura_extract.html b/src/apps/web_scraping/trafilatura_extract.html new file mode 100644 index 0000000..5bc7cd7 --- /dev/null +++ b/src/apps/web_scraping/trafilatura_extract.html @@ -0,0 +1,165 @@ + + + + + + Trafilatura Extract Result + + + + +
+
Waiting for data...
+ + + + +
+ + + + diff --git a/src/humcp/auth.py b/src/humcp/auth.py index 3190838..ba8dd0c 100644 --- a/src/humcp/auth.py +++ b/src/humcp/auth.py @@ -4,7 +4,9 @@ import logging import os import time +from contextvars import ContextVar from urllib.parse import unquote +from uuid import UUID from dotenv import load_dotenv from fastmcp.server.auth.providers.google import GoogleProvider @@ -18,6 +20,40 @@ logger = logging.getLogger("humcp.auth") +# ContextVar set by APIKeyMiddleware for REST requests +_current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None) + + +def get_current_user_id() -> UUID | None: + """Return the authenticated user's UUID, or None if unauthenticated. + + Resolution order: + 1. FastMCP access token (MCP path — set by JWTVerifier middleware) + 2. ContextVar (REST path — set by APIKeyMiddleware) + """ + # 1. MCP path: FastMCP's get_access_token() + try: + from fastmcp.server.dependencies import get_access_token + + token = get_access_token() + if token is not None: + sub = token.client_id or token.claims.get("sub") + if sub: + return UUID(sub) + except (RuntimeError, ValueError, ImportError): + pass + + # 2. REST path: ContextVar set by middleware + user_id_str = _current_user_id.get() + if user_id_str: + try: + return UUID(user_id_str) + except ValueError: + logger.warning("Invalid user_id in ContextVar: %s", user_id_str) + + return None + + # Enable debug logging for OAuth to diagnose authentication issues logging.getLogger("fastmcp.server.auth").setLevel(logging.DEBUG) logging.getLogger("mcp.server.auth").setLevel(logging.DEBUG) @@ -33,7 +69,7 @@ def is_auth_enabled() -> bool: False if AUTH_ENABLED is set to 'false' """ auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() - return auth_enabled in ("true") + return auth_enabled == "true" def has_google_credentials() -> bool: diff --git a/src/humcp/credentials.py b/src/humcp/credentials.py new file mode 100644 index 0000000..57a4c7c --- /dev/null +++ b/src/humcp/credentials.py @@ -0,0 +1,15 @@ +"""Credential resolution utilities (env-var only).""" + +import os + + +async def resolve_credential(env_var_name: str) -> str | None: + """Resolve a credential from environment variables. + + Args: + env_var_name: Name of the environment variable to read. + + Returns: + Value of the environment variable, or None if not set. + """ + return os.getenv(env_var_name) diff --git a/src/humcp/decorator.py b/src/humcp/decorator.py index 3aca7cf..ba40e3a 100644 --- a/src/humcp/decorator.py +++ b/src/humcp/decorator.py @@ -17,6 +17,7 @@ "is_tool", "get_tool_name", "get_tool_category", + "get_tool_app", "RegisteredTool", "ToolMetadata", ] @@ -30,6 +31,7 @@ class ToolMetadata: name: str category: str + app: str class RegisteredTool(NamedTuple): @@ -38,21 +40,25 @@ class RegisteredTool(NamedTuple): Attributes: tool: The FastMCP FunctionTool object (has name, description, parameters, fn). category: Category for REST endpoint grouping. + app: App grouping (defaults to filename stem). """ tool: FunctionTool category: str + app: str def tool( tool_name: str | None = None, category: str | None = None, + app: str | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Mark a function as an MCP tool. Args: tool_name: Tool name for MCP registration. Defaults to function name. category: Tool category for grouping. Defaults to parent folder name. + app: App grouping. Defaults to filename stem. Example: @tool() # name from function, category from file path @@ -68,16 +74,25 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: # Resolve name (default to function name) resolved_name = tool_name if tool_name is not None else func.__name__ - # Resolve category (default to parent folder name) + # Resolve category (default to parent folder name) and app (default to filename stem) resolved_category = category - if resolved_category is None: + resolved_app = app + if resolved_category is None or resolved_app is None: try: file_path = Path(inspect.getfile(func)) - resolved_category = file_path.parent.name + if resolved_category is None: + resolved_category = file_path.parent.name + if resolved_app is None: + resolved_app = file_path.stem except (TypeError, OSError): - resolved_category = "uncategorized" - - metadata = ToolMetadata(name=resolved_name, category=resolved_category) + if resolved_category is None: + resolved_category = "uncategorized" + if resolved_app is None: + resolved_app = "unknown" + + metadata = ToolMetadata( + name=resolved_name, category=resolved_category, app=resolved_app + ) setattr(func, TOOL_ATTR, metadata) return func @@ -103,3 +118,11 @@ def get_tool_category(func: Callable[..., Any]) -> str: if isinstance(metadata, ToolMetadata): return metadata.category return "uncategorized" + + +def get_tool_app(func: Callable[..., Any]) -> str: + """Get tool app from a decorated function.""" + metadata = getattr(func, TOOL_ATTR, None) + if isinstance(metadata, ToolMetadata): + return metadata.app + return "unknown" diff --git a/src/humcp/middleware.py b/src/humcp/middleware.py new file mode 100644 index 0000000..5bcabe6 --- /dev/null +++ b/src/humcp/middleware.py @@ -0,0 +1,84 @@ +"""API Key authentication middleware for service-to-service calls.""" + +import logging +import os +import secrets + +from fastapi import Request +from jose import JWTError, jwt +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from src.humcp.auth import _current_user_id + +logger = logging.getLogger(__name__) + +SERVICE_API_KEY = os.getenv("SERVICE_API_KEY", "") +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") +JWT_ALGORITHM = "HS256" + +# Log warning if no API key is configured +if not SERVICE_API_KEY: + logger.warning( + "SERVICE_API_KEY not configured. API key authentication is disabled. " + "Set SERVICE_API_KEY environment variable for production." + ) + + +class APIKeyMiddleware(BaseHTTPMiddleware): + """ + Middleware to validate X-API-Key header for service-to-service calls. + + Also extracts user identity from Authorization Bearer JWT when present + and stores it in a ContextVar for downstream tool handlers. + + Public paths (/, /docs, etc.) are exempt from authentication. + Uses constant-time comparison to prevent timing attacks. + """ + + # Paths that don't require authentication + PUBLIC_PATHS = {"/", "/docs", "/openapi.json", "/redoc", "/playground"} + + async def dispatch(self, request: Request, call_next): + # Skip auth for public paths + if request.url.path in self.PUBLIC_PATHS: + return await call_next(request) + + # Skip auth if no API key is configured (development mode) + if not SERVICE_API_KEY: + self._extract_user_id(request) + return await call_next(request) + + # Validate API key using constant-time comparison + api_key = request.headers.get("X-API-Key") + if not api_key or not secrets.compare_digest(api_key, SERVICE_API_KEY): + logger.warning("Invalid API key attempt for path: %s", request.url.path) + return JSONResponse( + status_code=401, + content={"detail": "Invalid or missing API key"}, + ) + + # Extract user identity from Bearer JWT (if present) + self._extract_user_id(request) + + return await call_next(request) + + @staticmethod + def _extract_user_id(request: Request) -> None: + """Decode Bearer JWT and store user_id in ContextVar for REST tool handlers.""" + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer ") or not JWT_SECRET_KEY: + _current_user_id.set(None) + return + + token = auth_header[7:] + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + sub = payload.get("sub") + if sub: + _current_user_id.set(sub) + else: + _current_user_id.set(None) + except JWTError: + logger.debug("Failed to decode Bearer JWT in REST request") + _current_user_id.set(None) diff --git a/src/humcp/permissions.py b/src/humcp/permissions.py new file mode 100644 index 0000000..39213ff --- /dev/null +++ b/src/humcp/permissions.py @@ -0,0 +1,73 @@ +"""Simplified permission checker for standalone HuMCP deployment. + +This module provides auth/permission primitives without requiring the +shared.auth IAM library (graphite-workflow-builder internal). It can be +upgraded to full IAM checks when shared.auth is available. +""" + +import logging +import os +from uuid import UUID + +from fastapi import HTTPException, status + +from src.humcp.auth import get_current_user_id + +logger = logging.getLogger(__name__) + +TOOLSET_REQUIRE_AUTH = os.getenv("TOOLSET_REQUIRE_AUTH", "").lower() == "true" + + +async def require_auth() -> UUID | None: + """Require authentication without checking a specific resource permission. + + Returns: + user_id if authenticated, None if no auth context (dev mode). + + Raises: + HTTPException 401: if TOOLSET_REQUIRE_AUTH is true and no user context. + """ + user_id = get_current_user_id() + if user_id is None and TOOLSET_REQUIRE_AUTH: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + return user_id + + +STRICT_PERMISSIONS = os.getenv("STRICT_PERMISSIONS", "").lower() == "true" + + +async def check_permission( + object_type: str, + object_id: str, + relation: str = "viewer", +) -> UUID | None: + """Get current user and check permission on a resource. + + Simplified version: delegates to require_auth() only. + Upgrade to full IAM checks when shared.auth is available. + + When STRICT_PERMISSIONS=true, rejects all calls since no IAM backend + is configured to actually verify fine-grained permissions. + + Returns: + user_id if authenticated, None if no auth context (dev mode). + + Raises: + HTTPException 401: if TOOLSET_REQUIRE_AUTH is true and no user context. + HTTPException 403: if STRICT_PERMISSIONS is true (no IAM backend). + """ + if STRICT_PERMISSIONS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Fine-grained permissions not configured. Set up IAM backend or disable STRICT_PERMISSIONS.", + ) + logger.warning( + "Permission check stub: object_type=%s object_id=%s relation=%s (not enforced)", + object_type, + object_id, + relation, + ) + return await require_auth() diff --git a/src/humcp/playground.py b/src/humcp/playground.py new file mode 100644 index 0000000..5346e81 --- /dev/null +++ b/src/humcp/playground.py @@ -0,0 +1,495 @@ +"""HuMCP Playground - interactive tool browser and executor.""" + + +def get_playground_html() -> str: + """Return self-contained HTML playground page.""" + return """ + + + + + HuMCP Playground + + + + + + +
+
+
+ +
+

HuMCP Playground

+
+
+ + Swagger + MCP +
+
+ + +
+ + + + + +
+
+ Select a tool from the sidebar to get started +
+ +
+
+ + + +""" diff --git a/src/humcp/routes.py b/src/humcp/routes.py index d2b5275..2c0b7b2 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -3,9 +3,10 @@ import asyncio import base64 import hashlib +import json import logging -import os import secrets +import time from pathlib import Path from typing import Any from urllib.parse import urlencode @@ -18,6 +19,7 @@ from src.humcp.decorator import RegisteredTool from src.humcp.schemas import ( + AppSummary, CategoryInfo, CategorySummary, GetCategoryResponse, @@ -35,11 +37,41 @@ logger = logging.getLogger("humcp.routes") # Store for PKCE verifiers and registered client -_pkce_store: dict[str, str] = {} +_PKCE_TTL_SECONDS = 600 # 10 minutes +_PKCE_MAX_ENTRIES = 1000 +_pkce_store: dict[str, tuple[str, float]] = {} # state -> (verifier, created_at) _browser_client: dict[str, str] = {} _registration_lock = asyncio.Lock() +def _pkce_cleanup() -> None: + """Remove expired PKCE entries.""" + now = time.monotonic() + expired = [k for k, (_, t) in _pkce_store.items() if now - t > _PKCE_TTL_SECONDS] + for k in expired: + del _pkce_store[k] + + +def _pkce_set(state: str, verifier: str) -> None: + """Store a PKCE verifier with TTL.""" + _pkce_cleanup() + if len(_pkce_store) >= _PKCE_MAX_ENTRIES: + oldest = min(_pkce_store, key=lambda k: _pkce_store[k][1]) + del _pkce_store[oldest] + _pkce_store[state] = (verifier, time.monotonic()) + + +def _pkce_pop(state: str) -> str | None: + """Pop a PKCE verifier, returning None if expired or missing.""" + entry = _pkce_store.pop(state, None) + if entry is None: + return None + verifier, created = entry + if time.monotonic() - created > _PKCE_TTL_SECONDS: + return None + return verifier + + def _format_tag(category: str) -> str: """Format category as display tag: 'local_files' -> 'Local Files'.""" return category.replace("_", " ").title() @@ -134,41 +166,16 @@ async def optional_rest_auth(request: Request): def _register_auth_routes( app: FastAPI, - tools: list[RegisteredTool], auth_provider: Any, - title: str, - version: str, ) -> None: - """Register authentication and info routes. + """Register authentication routes (login/logout). - When auth_provider is None (AUTH_ENABLED=false), only the root info endpoint - is registered. Login/logout endpoints are skipped. + When auth_provider is None (AUTH_ENABLED=false), no routes are registered. Args: app: FastAPI application. - tools: List of registered tools. auth_provider: FastMCP auth provider for OAuth operations (None if disabled). - title: App title for info endpoint. - version: App version for info endpoint. """ - mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") - auth_enabled = auth_provider is not None - - @app.get("/", tags=["Info"]) - async def root(): - """Server information endpoint.""" - endpoints = {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"} - if auth_enabled: - endpoints["login"] = "/login" - return { - "name": title, - "version": version, - "mcp_server": mcp_url, - "tools_count": len(tools), - "auth_enabled": auth_enabled, - "endpoints": endpoints, - } - # Skip login/logout endpoints if auth is disabled if not auth_provider: logger.info( @@ -229,7 +236,7 @@ async def login(request: Request): # Store verifier for later token exchange state = secrets.token_urlsafe(16) - _pkce_store[state] = code_verifier + _pkce_set(state, code_verifier) # Use the same scopes as MCP authentication scopes = ( @@ -284,7 +291,7 @@ async def login_callback( ) # Get the stored PKCE verifier - code_verifier = _pkce_store.pop(state, None) + code_verifier = _pkce_pop(state) if not code_verifier: return HTMLResponse( content="

Invalid State

Session expired or invalid state

Try again", @@ -343,7 +350,7 @@ async def login_callback(

You are now authenticated.

Go to Swagger UI

@@ -377,6 +384,7 @@ def register_routes( auth_provider: Any = None, title: str = "HuMCP Server", version: str = "1.0.0", + apps_count: int = 0, ) -> None: """Register all REST routes including tools and auth endpoints. @@ -387,6 +395,7 @@ def register_routes( auth_provider: FastMCP auth provider for OAuth operations (None if auth disabled). title: App title for info endpoint. version: App version for info endpoint. + apps_count: Number of MCP App bundles available. """ # Build lookups categories = _build_categories(tools) @@ -412,7 +421,7 @@ def register_routes( skills = discover_skills(tools_path) # Register auth endpoints only when auth is enabled - _register_auth_routes(app, tools, auth_provider, title, version) + _register_auth_routes(app, auth_provider) # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) @@ -423,6 +432,7 @@ async def list_tools() -> ListToolsResponse: cat: CategorySummary( count=len(items), tools=[ToolSummary(**t) for t in items], + apps=_build_apps(items), skill=SkillMetadata( name=skills[cat].name, description=skills[cat].description ) @@ -438,10 +448,12 @@ async def get_category(category: str) -> GetCategoryResponse: if category not in categories: raise HTTPException(404, f"Category '{category}' not found") skill = skills.get(category) + items = categories[category] return GetCategoryResponse( category=category, - count=len(categories[category]), - tools=[ToolSummary(**t) for t in categories[category]], + count=len(items), + tools=[ToolSummary(**t) for t in items], + apps=_build_apps(items), skill=SkillFull( name=skill.name, description=skill.description, content=skill.content ) @@ -459,6 +471,7 @@ async def get_tool(category: str, tool_name: str) -> GetToolResponse: return GetToolResponse( name=reg.tool.name, category=reg.category, + app=reg.app, description=reg.tool.description, endpoint=f"/tools/{reg.tool.name}", input_schema=InputSchema(**reg.tool.parameters), @@ -534,11 +547,27 @@ def _build_categories(tools: list[RegisteredTool]) -> dict[str, list[dict[str, A "name": reg.tool.name, "description": reg.tool.description, "endpoint": f"/tools/{reg.tool.name}", + "app": reg.app, } ) return cats +def _build_apps(tool_dicts: list[dict[str, Any]]) -> list[AppSummary]: + """Group tool dicts by app field into AppSummary list.""" + app_map: dict[str, list[dict[str, Any]]] = {} + for t in tool_dicts: + app_map.setdefault(t["app"], []).append(t) + return [ + AppSummary( + name=app_name, + count=len(items), + tools=[ToolSummary(**t) for t in items], + ) + for app_name, items in sorted(app_map.items()) + ] + + def build_openapi_tags(tools: list[RegisteredTool]) -> list[dict[str, str]]: """Build OpenAPI tag metadata.""" categories = sorted({reg.category for reg in tools}) diff --git a/src/humcp/schemas.py b/src/humcp/schemas.py index 0bd9653..48dfcc3 100644 --- a/src/humcp/schemas.py +++ b/src/humcp/schemas.py @@ -5,6 +5,14 @@ from pydantic import BaseModel, Field +class ToolResponse[T](BaseModel): + """Generic response wrapper for tool outputs.""" + + success: bool + data: T | None = None + error: str | None = None + + # Shared models class ToolSummary(BaseModel): """Summary of a tool in listings.""" @@ -12,6 +20,7 @@ class ToolSummary(BaseModel): name: str = Field(..., description="Tool name") description: str | None = Field(None, description="Tool description") endpoint: str = Field(..., description="API endpoint path") + app: str = Field(..., description="App grouping (filename stem)") class SkillMetadata(BaseModel): @@ -29,12 +38,23 @@ class SkillFull(BaseModel): content: str = Field(..., description="Skill markdown content") +class AppSummary(BaseModel): + """Summary of an app (file-level grouping within a category).""" + + name: str = Field(..., description="App name (filename stem)") + count: int = Field(..., description="Number of tools in app") + tools: list[ToolSummary] = Field(..., description="List of tools in app") + + # GET /tools response class CategorySummary(BaseModel): """Summary of a category in the tools listing.""" count: int = Field(..., description="Number of tools in category") tools: list[ToolSummary] = Field(..., description="List of tools") + apps: list[AppSummary] = Field( + default_factory=list, description="Tools grouped by app" + ) skill: SkillMetadata | None = Field(None, description="Skill metadata if available") @@ -54,6 +74,9 @@ class GetCategoryResponse(BaseModel): category: str = Field(..., description="Category name") count: int = Field(..., description="Number of tools in category") tools: list[ToolSummary] = Field(..., description="List of tools") + apps: list[AppSummary] = Field( + default_factory=list, description="Tools grouped by app" + ) skill: SkillFull | None = Field(None, description="Full skill information") @@ -73,6 +96,7 @@ class GetToolResponse(BaseModel): name: str = Field(..., description="Tool name") category: str = Field(..., description="Tool category") + app: str = Field(..., description="App grouping (filename stem)") description: str | None = Field(None, description="Tool description") endpoint: str = Field(..., description="API endpoint path") input_schema: InputSchema = Field(..., description="JSON Schema for tool input") diff --git a/src/humcp/server.py b/src/humcp/server.py index 2e0b22a..d4446ed 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -3,6 +3,7 @@ import importlib.util import inspect import logging +import os import sys from contextlib import asynccontextmanager from pathlib import Path @@ -10,16 +11,22 @@ from dotenv import load_dotenv from fastapi import FastAPI +from fastapi.responses import HTMLResponse from fastmcp import FastMCP +from fastmcp.server.apps import AppConfig +from fastmcp.server.auth.providers.jwt import JWTVerifier from src.humcp.auth import create_auth_provider from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config from src.humcp.decorator import ( RegisteredTool, + get_tool_app, get_tool_category, get_tool_name, is_tool, ) +from src.humcp.middleware import APIKeyMiddleware +from src.humcp.playground import get_playground_html from src.humcp.routes import build_openapi_tags, register_routes load_dotenv() @@ -56,13 +63,61 @@ def _load_modules(tools_path: Path) -> list[ModuleType]: return modules +def _discover_apps(apps_path: Path) -> dict[str, Path]: + """Discover MCP App HTML bundles in the apps directory. + + Returns mapping of tool_name -> html_file_path. + Convention: apps/{category}/{tool_name}.html + """ + if not apps_path.exists(): + return {} + + app_map: dict[str, Path] = {} + for html_file in sorted(apps_path.rglob("*.html")): + tool_name = html_file.stem + app_map[tool_name] = html_file + logger.debug("Discovered app: %s -> %s", tool_name, html_file) + + return app_map + + +def _make_app_resource_fn(path: Path): + """Create a named function for serving an app HTML file as MCP resource.""" + + async def read_app_html() -> str: + return path.read_text(encoding="utf-8") + + read_app_html.__name__ = f"app_{path.stem}" + read_app_html.__qualname__ = f"app_{path.stem}" + return read_app_html + + +def _register_app_resources( + mcp: FastMCP, apps_path: Path, app_map: dict[str, Path] +) -> None: + """Register ui:// MCP resources for discovered app HTML bundles.""" + for tool_name, html_file in app_map.items(): + relative = html_file.relative_to(apps_path) + uri = f"ui://{relative.as_posix()}" + reader_fn = _make_app_resource_fn(html_file) + mcp.resource(uri, name=f"app_{tool_name}")(reader_fn) + logger.debug("Registered ui:// resource: %s", uri) + + def _discover_and_register( - mcp: FastMCP, modules: list[ModuleType] + mcp: FastMCP, + modules: list[ModuleType], + app_map: dict[str, Path] | None = None, + apps_path: Path | None = None, ) -> list[RegisteredTool]: """Discover @tool functions and register with FastMCP. + If app_map is provided, tools with matching HTML apps get AppConfig attached + for MCP Apps support. + Returns list of RegisteredTool (FunctionTool + category). """ + app_map = app_map or {} tools: list[RegisteredTool] = [] seen_names: set[str] = set() @@ -74,6 +129,7 @@ def _discover_and_register( # Get tool metadata from decorator tool_name = get_tool_name(func) category = get_tool_category(func) + app = get_tool_app(func) # Check for duplicates if tool_name in seen_names: @@ -82,9 +138,20 @@ def _discover_and_register( seen_names.add(tool_name) - # Register with FastMCP using custom name - returns FunctionTool - fn_tool = mcp.tool(name=tool_name)(func) - tools.append(RegisteredTool(tool=fn_tool, category=category)) + # Build app config if matching HTML app exists + app_config = None + if tool_name in app_map and apps_path: + relative = app_map[tool_name].relative_to(apps_path) + resource_uri = f"ui://{relative.as_posix()}" + app_config = AppConfig(resource_uri=resource_uri) # type: ignore[call-arg] + logger.debug("Attached app to tool '%s': %s", tool_name, resource_uri) + + # Register with FastMCP (v3 returns the original function, not FunctionTool) + mcp.tool(name=tool_name, app=app_config)(func) + + # Access the FunctionTool synchronously from FastMCP's internal registry + fn_tool = mcp._local_provider._components[f"tool:{tool_name}@"] + tools.append(RegisteredTool(tool=fn_tool, category=category, app=app)) # type: ignore[arg-type] logger.debug("Registered: %s (category: %s)", fn_tool.name, category) return tools @@ -93,22 +160,41 @@ def _discover_and_register( def create_app( tools_path: Path | str | None = None, config_path: Path | str | None = None, + apps_path: Path | str | None = None, title: str = "HuMCP Server", description: str = "REST and MCP endpoints for tools", version: str = "1.0.0", ) -> FastAPI: - """Create FastAPI app with REST (/tools) and MCP (/mcp) endpoints.""" + """Create FastAPI app with REST (/tools), MCP (/mcp), and Apps (/apps) endpoints.""" path = Path(tools_path) if tools_path else Path(__file__).parent.parent / "tools" - - # Create auth provider (respects AUTH_ENABLED env var) - auth_provider = create_auth_provider() - - # Create MCP server - mcp = FastMCP("HuMCP Server", auth=auth_provider) + a_path = Path(apps_path) if apps_path else Path(__file__).parent.parent / "apps" + + # Select MCP auth provider: + # - JWT_SECRET_KEY set → JWTVerifier (graphite/service-to-service mode) + # - GOOGLE_OAUTH_CLIENT_ID + AUTH_ENABLED=true → GoogleProvider (standalone mode) + # - Neither → no MCP auth (dev mode) + jwt_secret = os.getenv("JWT_SECRET_KEY", "") + if jwt_secret: + mcp_auth = JWTVerifier(public_key=jwt_secret, algorithm="HS256") + mcp = FastMCP("HuMCP Server", auth=mcp_auth) + auth_provider = None # Google OAuth not used when JWT mode is active + logger.info("Using JWT auth for MCP (JWT_SECRET_KEY set)") + else: + # Fall back to Google OAuth (or None if not configured) + auth_provider = create_auth_provider() + mcp = FastMCP("HuMCP Server", auth=auth_provider) + if auth_provider: + logger.info("Using Google OAuth for MCP") + + # Discover MCP App HTML bundles + app_map = _discover_apps(a_path) + if app_map: + logger.info("Discovered %d MCP App bundles from %s", len(app_map), a_path) + _register_app_resources(mcp, a_path, app_map) # Load modules and register tools with FastMCP modules = _load_modules(path) - tools = _discover_and_register(mcp, modules) + tools = _discover_and_register(mcp, modules, app_map=app_map, apps_path=a_path) logger.info("Registered %d tools from %s", len(tools), path) # Filter tools by config @@ -134,7 +220,10 @@ async def lifespan(_: FastAPI): openapi_tags=build_openapi_tags(filtered), ) - # Register all REST routes (tools, auth, info endpoints) + # Add API key authentication middleware + app.add_middleware(APIKeyMiddleware) + + # Register REST routes (tools, auth, info endpoints) register_routes( app, tools_path=path, @@ -142,16 +231,72 @@ async def lifespan(_: FastAPI): auth_provider=auth_provider, title=title, version=version, + apps_count=len(app_map), ) logger.info("Registered %d REST endpoints", len(filtered)) - # Mount OAuth routes at root level - # This includes: /.well-known/*, /authorize, /token, /register, /auth/callback, /consent + # Mount OAuth routes at root level (Google OAuth mode only) if auth_provider: oauth_routes = auth_provider.get_routes(mcp_path="/mcp") for route in oauth_routes: app.routes.append(route) logger.info("Mounted %d OAuth routes at root level", len(oauth_routes)) + # Apps REST delivery endpoint + @app.get("/apps/{tool_name}", tags=["Apps"], response_class=HTMLResponse) + async def get_app_html(tool_name: str) -> HTMLResponse: + """Serve MCP App HTML bundle for a tool (REST delivery).""" + html_file = app_map.get(tool_name) + if not html_file or not html_file.exists(): + return HTMLResponse(status_code=404, content="App not found") + return HTMLResponse(content=html_file.read_text(encoding="utf-8")) + + @app.get("/apps", tags=["Apps"]) + async def list_apps() -> dict: + """List available MCP App bundles.""" + return { + "total_apps": len(app_map), + "apps": [ + { + "tool_name": name, + "rest_endpoint": f"/apps/{name}", + "mcp_resource": f"ui://{p.relative_to(a_path).as_posix()}", + } + for name, p in sorted(app_map.items()) + ], + } + + # Playground endpoint + @app.get("/playground", tags=["Info"], response_class=HTMLResponse) + async def playground(): + """Interactive tool browser and executor.""" + return HTMLResponse(content=get_playground_html()) + + # Root info endpoint + mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") + auth_enabled = auth_provider is not None + + @app.get("/", tags=["Info"]) + async def root(): + """Server information endpoint.""" + endpoints: dict[str, str] = { + "docs": "/docs", + "playground": "/playground", + "tools": "/tools", + "apps": "/apps", + "mcp": "/mcp", + } + if auth_enabled: + endpoints["login"] = "/login" + return { + "name": title, + "version": version, + "mcp_server": mcp_url, + "tools_count": len(filtered), + "apps_count": len(app_map), + "auth_enabled": auth_enabled, + "endpoints": endpoints, + } + app.mount("/mcp", mcp_http_app) return app diff --git a/src/humcp/storage_path.py b/src/humcp/storage_path.py new file mode 100644 index 0000000..2d323bb --- /dev/null +++ b/src/humcp/storage_path.py @@ -0,0 +1,201 @@ +"""Storage path utilities for resolving minio:// URLs to local files. + +This module provides utilities to parse and resolve storage paths in the format: + minio://bucket/path/to/object + +Tools can use these utilities to transparently support both local files and +storage objects as input parameters. + +Example: + >>> from src.humcp.storage_path import resolve_path, is_storage_path + >>> + >>> # Check if path is a storage URL + >>> is_storage_path("minio://datasets/data.csv") # True + >>> is_storage_path("/local/path/data.csv") # False + >>> + >>> # Resolve path (downloads storage files to temp) + >>> async with resolve_path("minio://datasets/data.csv") as local_path: + ... with open(local_path) as f: + ... data = f.read() + >>> # Temp file is automatically cleaned up +""" + +import logging +import os +import tempfile +from contextlib import asynccontextmanager +from pathlib import Path +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +STORAGE_SCHEME = "minio" + + +def is_storage_path(path: str) -> bool: + """Check if a path is a storage URL. + + Args: + path: Path string to check. + + Returns: + True if path starts with 'minio://', False otherwise. + """ + return path.startswith(f"{STORAGE_SCHEME}://") + + +def parse_storage_path(path: str) -> tuple[str, str]: + """Parse a storage URL into bucket and object name. + + Args: + path: Storage URL in format 'minio://bucket/path/to/object'. + + Returns: + Tuple of (bucket, object_name). + + Raises: + ValueError: If path is not a valid storage URL. + + Example: + >>> parse_storage_path("minio://my-bucket/data/file.csv") + ('my-bucket', 'data/file.csv') + """ + if not is_storage_path(path): + raise ValueError(f"Not a storage path: {path}") + + parsed = urlparse(path) + bucket = parsed.netloc + # Remove leading slash from path + object_name = parsed.path.lstrip("/") + + if not bucket: + raise ValueError(f"Invalid storage path (no bucket): {path}") + if not object_name: + raise ValueError(f"Invalid storage path (no object): {path}") + + return bucket, object_name + + +def get_storage_path(bucket: str, object_name: str) -> str: + """Construct a storage URL from bucket and object name. + + Args: + bucket: Bucket name. + object_name: Object path within the bucket. + + Returns: + Storage URL string. + + Example: + >>> get_storage_path("my-bucket", "data/file.csv") + 'minio://my-bucket/data/file.csv' + """ + return f"{STORAGE_SCHEME}://{bucket}/{object_name}" + + +async def download_to_temp(bucket: str, object_name: str) -> str: + """Download a storage object to a temporary file. + + Args: + bucket: Bucket name. + object_name: Object path within the bucket. + + Returns: + Path to the temporary file. + + Raises: + ValueError: If storage client is not configured. + Exception: If download fails. + """ + # Import here to avoid circular imports + from src.tools.storage.tools import get_client, validate_bucket + + # Validate bucket access + if error := validate_bucket(bucket): + raise ValueError(error) + + client = get_client() + + # Get file extension from object name for proper temp file naming + ext = Path(object_name).suffix or "" + + # Create temp file with same extension + fd, temp_path = tempfile.mkstemp(suffix=ext) + os.close(fd) + + try: + logger.info( + "Downloading storage object bucket=%s object=%s", bucket, object_name + ) + client.fget_object(bucket, object_name, temp_path) + logger.info("Downloaded to temp file path=%s", temp_path) + return temp_path + except Exception: + # Clean up temp file on error + if os.path.exists(temp_path): + os.unlink(temp_path) + raise + + +@asynccontextmanager +async def resolve_path(path: str): + """Context manager that resolves a path, downloading from storage if needed. + + For local paths, yields the path unchanged. For storage paths, downloads + the object to a temporary file, yields the temp path, then cleans up. + + Args: + path: Local file path or storage URL (minio://bucket/object). + + Yields: + Local file path (either original or temp file). + + Example: + >>> async with resolve_path("minio://datasets/data.csv") as local_path: + ... with open(local_path) as f: + ... data = f.read() + >>> # Temp file is automatically cleaned up + + >>> async with resolve_path("/local/data.csv") as local_path: + ... # local_path is "/local/data.csv" unchanged + ... with open(local_path) as f: + ... data = f.read() + """ + if not is_storage_path(path): + # Local path - yield unchanged + yield path + return + + # Storage path - download to temp + bucket, object_name = parse_storage_path(path) + temp_path = await download_to_temp(bucket, object_name) + + try: + yield temp_path + finally: + # Clean up temp file + if os.path.exists(temp_path): + logger.debug("Cleaning up temp file path=%s", temp_path) + os.unlink(temp_path) + + +async def resolve_path_simple(path: str) -> str: + """Resolve a path without context manager (caller must clean up). + + For local paths, returns the path unchanged. For storage paths, downloads + the object to a temporary file and returns the temp path. + + WARNING: When using storage paths, the caller is responsible for cleaning + up the temporary file. Prefer using resolve_path() context manager instead. + + Args: + path: Local file path or storage URL. + + Returns: + Local file path (either original or temp file). + """ + if not is_storage_path(path): + return path + + bucket, object_name = parse_storage_path(path) + return await download_to_temp(bucket, object_name) diff --git a/src/tools/api/SKILL.md b/src/tools/api/SKILL.md new file mode 100644 index 0000000..13d7fe0 --- /dev/null +++ b/src/tools/api/SKILL.md @@ -0,0 +1,90 @@ +--- +name: http-api-client +description: Make HTTP requests to any URL or API endpoint. Use when the user needs to call a REST API, fetch data from a URL, send webhooks, or test HTTP endpoints. Supports GET, POST, PUT, PATCH, DELETE methods with custom headers and JSON body. +--- + +# HTTP API Client Tools + +General-purpose HTTP client for making requests to any URL or API endpoint. + +## Available Tools + +| Tool | API Key Required | Best For | +|------|-----------------|----------| +| `http_request` | None | Making HTTP requests to any URL | + +## Quick Examples + +### GET request + +```python +result = await http_request( + method="GET", + url="https://api.example.com/users", + headers={"Authorization": "Bearer token123"} +) +``` + +### POST request with JSON body + +```python +result = await http_request( + method="POST", + url="https://api.example.com/users", + headers={"Content-Type": "application/json"}, + body={"name": "Alice", "email": "alice@example.com"}, + timeout=15 +) +``` + +### PUT request + +```python +result = await http_request( + method="PUT", + url="https://api.example.com/users/123", + body={"name": "Alice Updated"} +) +``` + +### DELETE request + +```python +result = await http_request( + method="DELETE", + url="https://api.example.com/users/123" +) +``` + +## Response Format + +```json +{ + "success": true, + "data": { + "status_code": 200, + "headers": {"content-type": "application/json"}, + "body": {"id": 1, "name": "Alice"}, + "url": "https://api.example.com/users", + "method": "GET", + "elapsed_ms": 152.34 + } +} +``` + +On failure: + +```json +{ + "success": false, + "error": "Connection failed: ..." +} +``` + +## When to Use + +- **Call REST APIs**: Make GET/POST/PUT/PATCH/DELETE requests +- **Fetch data**: Retrieve JSON or text from any URL +- **Send webhooks**: POST data to webhook endpoints +- **Test endpoints**: Verify API responses and status codes +- **Integration glue**: Connect to any HTTP-based service diff --git a/src/tools/api/__init__.py b/src/tools/api/__init__.py new file mode 100644 index 0000000..22f9767 --- /dev/null +++ b/src/tools/api/__init__.py @@ -0,0 +1 @@ +# HTTP client and API integration tools diff --git a/src/tools/api/http_client.py b/src/tools/api/http_client.py new file mode 100644 index 0000000..9622318 --- /dev/null +++ b/src/tools/api/http_client.py @@ -0,0 +1,183 @@ +"""HTTP client tool for making arbitrary HTTP requests.""" + +from __future__ import annotations + +import ipaddress +import logging +import os +import socket +import time +from typing import Any +from urllib.parse import urlparse + +from src.humcp.decorator import tool +from src.tools.api.schemas import ( + HttpResponseData, + HttpResponseResponse, +) + + +def _is_private_url(url: str) -> bool: + """Check if URL resolves to a private/internal IP range.""" + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + return True + + # Block known metadata endpoints and localhost + blocked_hosts = { + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "metadata.google.internal", + "metadata.goog", + "169.254.169.254", + } + if hostname in blocked_hosts: + return True + + try: + resolved = socket.getaddrinfo(hostname, None) + for _, _, _, _, addr in resolved: + ip = ipaddress.ip_address(addr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + return True + except (socket.gaierror, ValueError): + pass + + return False + + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for HTTP client tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.http_client") + + +@tool() +async def http_request( + method: str, + url: str, + headers: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + timeout: int = 30, +) -> HttpResponseResponse: + """Make an HTTP request to any URL and return the response. + + A general-purpose HTTP client supporting GET, POST, PUT, PATCH, DELETE, + HEAD, and OPTIONS methods. The response body is automatically parsed as + JSON when the Content-Type indicates JSON; otherwise it is returned as + plain text. Includes automatic timing measurement for performance analysis. + + The request body (JSON) is only sent for POST, PUT, and PATCH methods; + it is silently ignored for other methods. Custom headers can be provided + for authentication (e.g., Bearer tokens, API keys) or content negotiation. + + Args: + method: HTTP method to use. Supported values: GET, POST, PUT, PATCH, + DELETE, HEAD, OPTIONS. Case-insensitive. + url: The full URL to send the request to. Must start with 'http://' or + 'https://'. Query parameters should be included in the URL. + headers: Optional dictionary of HTTP headers to include in the request. + Useful for Authorization, Content-Type overrides, or custom + API key headers (e.g., {"Authorization": "Bearer "}). + body: Optional dictionary to send as a JSON request body. Only used for + POST, PUT, and PATCH methods. Set to None for GET/DELETE requests. + timeout: Request timeout in seconds. The request will be aborted if no + response is received within this duration. Defaults to 30 seconds. + Increase for slow APIs or large payloads; decrease for + latency-sensitive operations. Range: 1-300 recommended. + + Returns: + Response containing the HTTP status code, response headers, parsed body + (JSON object or plain text), final URL (after redirects), HTTP method, + and elapsed time in milliseconds. + """ + try: + normalized_method = method.upper() + allowed_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} + if normalized_method not in allowed_methods: + return HttpResponseResponse( + success=False, + error=f"Unsupported HTTP method: {method}. Use one of: {', '.join(sorted(allowed_methods))}", + ) + + if not url.startswith(("http://", "https://")): + return HttpResponseResponse( + success=False, + error="URL must start with http:// or https://", + ) + + if os.getenv("HTTP_ALLOW_PRIVATE", "").lower() != "true" and _is_private_url( + url + ): + return HttpResponseResponse( + success=False, + error="Requests to private/internal network addresses are blocked. Set HTTP_ALLOW_PRIVATE=true to allow.", + ) + + logger.info("HTTP %s %s", normalized_method, url) + start_time = time.monotonic() + + request_kwargs: dict[str, Any] = { + "method": normalized_method, + "url": url, + "timeout": timeout, + } + if headers is not None: + request_kwargs["headers"] = headers + if body is not None and normalized_method in {"POST", "PUT", "PATCH"}: + request_kwargs["json"] = body + + async with httpx.AsyncClient() as client: + response = await client.request(**request_kwargs) + + elapsed_ms = (time.monotonic() - start_time) * 1000 + + response_headers = dict(response.headers) + + response_body: Any + try: + response_body = response.json() + except Exception: + response_body = response.text + + data = HttpResponseData( + status_code=response.status_code, + headers=response_headers, + body=response_body, + url=str(response.url), + method=normalized_method, + elapsed_ms=round(elapsed_ms, 2), + ) + + logger.info( + "HTTP %s %s status=%d elapsed=%.1fms", + normalized_method, + url, + response.status_code, + elapsed_ms, + ) + return HttpResponseResponse(success=True, data=data) + except httpx.TimeoutException: + logger.exception("HTTP request timed out") + return HttpResponseResponse( + success=False, + error=f"Request timed out after {timeout} seconds", + ) + except httpx.ConnectError as e: + logger.exception("HTTP connection failed") + return HttpResponseResponse( + success=False, + error=f"Connection failed: {str(e)}", + ) + except Exception as e: + logger.exception("HTTP request failed") + return HttpResponseResponse( + success=False, error=f"HTTP request failed: {str(e)}" + ) diff --git a/src/tools/api/schemas.py b/src/tools/api/schemas.py new file mode 100644 index 0000000..7a07a16 --- /dev/null +++ b/src/tools/api/schemas.py @@ -0,0 +1,37 @@ +"""Pydantic output schemas for API tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# HTTP Client Schemas +# ============================================================================= + + +class HttpResponseData(BaseModel): + """Output data for http_request tool.""" + + status_code: int = Field(..., description="HTTP status code of the response") + headers: dict[str, str] = Field( + default_factory=dict, description="Response headers" + ) + body: Any = Field(None, description="Response body (JSON-parsed if possible)") + url: str = Field(..., description="The final URL that was requested") + method: str = Field(..., description="HTTP method used") + elapsed_ms: float | None = Field( + None, description="Request duration in milliseconds" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class HttpResponseResponse(ToolResponse[HttpResponseData]): + """Response schema for http_request tool.""" + + pass diff --git a/src/tools/audio/SKILL.md b/src/tools/audio/SKILL.md new file mode 100644 index 0000000..ee2a801 --- /dev/null +++ b/src/tools/audio/SKILL.md @@ -0,0 +1,131 @@ +--- +name: audio-tools +description: Text-to-speech synthesis, audio transcription, and music search. Use when the user needs to generate speech audio, transcribe audio files, or search for music on Spotify. +--- + +# Audio Tools + +Tools for text-to-speech, transcription, and music search. + +## ElevenLabs (Text-to-Speech) + +### Generate speech + +```python +result = await elevenlabs_text_to_speech( + text="Hello, welcome to the demo.", + voice_id="JBFqnCBsd6RMkjVDRZzb", + model_id="eleven_multilingual_v2", + output_format="mp3_44100_64" +) +``` + +### List voices + +```python +result = await elevenlabs_list_voices() +``` + +**Env:** `ELEVEN_LABS_API_KEY` + +## Cartesia (Text-to-Speech) + +### Generate speech + +```python +result = await cartesia_text_to_speech( + text="Hello from Cartesia.", + voice_id="78ab82d5-25be-4f7d-82b3-7ad64e5b85b2", + model_id="sonic-2", + output_format="mp3" +) +``` + +**Env:** `CARTESIA_API_KEY` + +## DesiVocal (Text-to-Speech) + +### Generate speech + +```python +result = await desi_vocal_tts( + text="Namaste, yeh ek demo hai.", + voice_id="f27d74e5-ea71-4697-be3e-f04bbd80c1a8" +) +``` + +### List voices + +```python +result = await desi_vocal_list_voices() +``` + +**Env:** `DESI_VOCAL_API_KEY` + +## MLX Transcribe (Speech-to-Text) + +### Transcribe audio + +```python +result = await mlx_transcribe( + audio_path="/path/to/audio.mp3", + model="mlx-community/whisper-large-v3-turbo", + language="en" +) +``` + +No API key required. Runs locally on Apple Silicon via MLX Whisper. + +**Requirements:** `pip install mlx-whisper`, `brew install ffmpeg` + +## Spotify (Music Search) + +### Search tracks + +```python +result = await spotify_search( + query="Bohemian Rhapsody", + search_type="track", + limit=10 +) +``` + +### Get track details + +```python +result = await spotify_get_track(track_id="4u7EnebtmKWzUH433cf5Qv") +``` + +### Get playlist + +```python +result = await spotify_get_playlist(playlist_id="37i9dQZF1DXcBWIGoYBM5M") +``` + +**Env:** `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` + +## Response Format + +All tools return: +```json +{ + "success": true, + "data": { ... } +} +``` + +On error: +```json +{ + "success": false, + "error": "Error description" +} +``` + +## When to Use + +- Generating speech audio from text (ElevenLabs, Cartesia, DesiVocal) +- Transcribing audio files to text (MLX Transcribe) +- Searching for songs, albums, or playlists (Spotify) +- Building voice-enabled workflows +- Creating audio content pipelines diff --git a/src/tools/audio/__init__.py b/src/tools/audio/__init__.py new file mode 100644 index 0000000..f4f55da --- /dev/null +++ b/src/tools/audio/__init__.py @@ -0,0 +1 @@ +# Audio tools: text-to-speech, transcription, and music search diff --git a/src/tools/audio/cartesia.py b/src/tools/audio/cartesia.py new file mode 100644 index 0000000..4308801 --- /dev/null +++ b/src/tools/audio/cartesia.py @@ -0,0 +1,148 @@ +"""Cartesia text-to-speech tool.""" + +from __future__ import annotations + +import base64 +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.audio.schemas import ( + CartesiaListVoicesData, + CartesiaListVoicesResponse, + CartesiaTTSData, + CartesiaTTSResponse, + CartesiaVoice, +) + +try: + import cartesia # type: ignore +except ImportError as err: + raise ImportError( + "cartesia is required for Cartesia tools. Install with: pip install cartesia" + ) from err + +logger = logging.getLogger("humcp.tools.cartesia") + + +@tool() +async def cartesia_text_to_speech( + text: str, + voice_id: str = "78ab82d5-25be-4f7d-82b3-7ad64e5b85b2", + model_id: str = "sonic-2", + output_format: str = "mp3", +) -> CartesiaTTSResponse: + """Convert text to speech using Cartesia API. + + Generates audio from text using a specified voice and model. + Returns the audio as base64-encoded content. + + Args: + text: The text to convert to speech. + voice_id: Cartesia voice ID to use. + model_id: Model to use for generation (default: sonic-2). + output_format: Audio container format (default: mp3). + + Returns: + Success flag with base64-encoded audio data or error message. + """ + try: + api_key = await resolve_credential("CARTESIA_API_KEY") + if not api_key: + return CartesiaTTSResponse( + success=False, + error="Cartesia API not configured. Set CARTESIA_API_KEY.", + ) + + logger.info( + "Cartesia TTS start voice_id=%s model_id=%s format=%s", + voice_id, + model_id, + output_format, + ) + + client = cartesia.Cartesia(api_key=api_key) + + format_config = { + "container": output_format, + "sample_rate": 44100, + "bit_rate": 128000, + "encoding": output_format, + } + + params = { + "model_id": model_id, + "transcript": text, + "voice": {"mode": "id", "id": voice_id}, + "output_format": format_config, + } + + audio_iterator = client.tts.bytes(**params) # type: ignore[arg-type] + audio_data = b"".join(chunk for chunk in audio_iterator) + + encoded_audio = base64.b64encode(audio_data).decode("utf-8") + + logger.info("Cartesia TTS complete size=%d bytes", len(audio_data)) + + return CartesiaTTSResponse( + success=True, + data=CartesiaTTSData( + audio_base64=encoded_audio, + format=output_format, + voice_id=voice_id, + model_id=model_id, + ), + ) + except Exception as e: + logger.exception("Cartesia TTS failed") + return CartesiaTTSResponse( + success=False, error=f"Cartesia TTS failed: {str(e)}" + ) + + +@tool() +async def cartesia_list_voices() -> CartesiaListVoicesResponse: + """List all available voices from Cartesia. + + Returns a list of voices with their IDs, names, descriptions, + and language information. + + Returns: + Success flag with list of available voices or error message. + """ + try: + api_key = await resolve_credential("CARTESIA_API_KEY") + if not api_key: + return CartesiaListVoicesResponse( + success=False, + error="Cartesia API not configured. Set CARTESIA_API_KEY.", + ) + + logger.info("Cartesia list voices start") + + client = cartesia.Cartesia(api_key=api_key) + voices_response = client.voices.list() + + voices = [ + CartesiaVoice( + voice_id=voice.get("id", ""), # type: ignore[attr-defined] + name=voice.get("name", "Unknown"), # type: ignore[attr-defined] + description=voice.get("description"), # type: ignore[attr-defined] + language=voice.get("language"), # type: ignore[attr-defined] + is_public=voice.get("is_public"), # type: ignore[attr-defined] + ) + for voice in voices_response + ] + + logger.info("Cartesia list voices complete count=%d", len(voices)) + + return CartesiaListVoicesResponse( + success=True, + data=CartesiaListVoicesData(voices=voices), + ) + except Exception as e: + logger.exception("Cartesia list voices failed") + return CartesiaListVoicesResponse( + success=False, + error=f"Cartesia list voices failed: {str(e)}", + ) diff --git a/src/tools/audio/desi_vocal.py b/src/tools/audio/desi_vocal.py new file mode 100644 index 0000000..a8e1bf1 --- /dev/null +++ b/src/tools/audio/desi_vocal.py @@ -0,0 +1,129 @@ +"""DesiVocal text-to-speech and voice listing tools.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.audio.schemas import ( + DesiVocalListVoicesData, + DesiVocalListVoicesResponse, + DesiVocalTTSData, + DesiVocalTTSResponse, + DesiVocalVoice, +) + +logger = logging.getLogger("humcp.tools.desi_vocal") + +DESI_VOCAL_BASE_URL = "https://prod-api2.desivocal.com/dv/api/v0/tts_api" + + +@tool() +async def desi_vocal_tts( + text: str, + voice_id: str = "f27d74e5-ea71-4697-be3e-f04bbd80c1a8", +) -> DesiVocalTTSResponse: + """Generate speech audio from text using DesiVocal API. + + Converts text to speech with support for multiple Indian languages and voices. + Returns the URL of the generated audio file. + + Args: + text: The text to convert to speech. + voice_id: DesiVocal voice ID to use for generation. + + Returns: + Success flag with audio URL or error message. + """ + try: + api_key = await resolve_credential("DESI_VOCAL_API_KEY") + if not api_key: + return DesiVocalTTSResponse( + success=False, + error="DesiVocal API not configured. Set DESI_VOCAL_API_KEY.", + ) + + logger.info("DesiVocal TTS start voice_id=%s", voice_id) + + url = f"{DESI_VOCAL_BASE_URL}/generate" + payload = { + "text": text, + "voice_id": voice_id, + } + headers = { + "X_API_KEY": api_key, + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + + response_json = response.json() + audio_url = response_json["s3_path"] + + logger.info("DesiVocal TTS complete audio_url=%s", audio_url) + + return DesiVocalTTSResponse( + success=True, + data=DesiVocalTTSData( + audio_url=audio_url, + voice_id=voice_id, + ), + ) + except Exception as e: + logger.exception("DesiVocal TTS failed") + return DesiVocalTTSResponse( + success=False, error=f"DesiVocal TTS failed: {str(e)}" + ) + + +@tool() +async def desi_vocal_list_voices() -> DesiVocalListVoicesResponse: + """List all available voices from DesiVocal. + + Returns a list of voices with their IDs, names, genders, types, + supported languages, and preview URLs. + + Returns: + Success flag with list of available voices or error message. + """ + try: + logger.info("DesiVocal list voices start") + + url = f"{DESI_VOCAL_BASE_URL}/voices" + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(url) + response.raise_for_status() + + voices_data = response.json() + + voices = [ + DesiVocalVoice( + voice_id=voice_id, + name=voice_info["name"], + gender=voice_info.get("audio_gender"), + voice_type=voice_info.get("voice_type"), + language=", ".join(voice_info.get("languages", [])), + preview_url=next( + iter(voice_info.get("preview_path", {}).values()), None + ), + ) + for voice_id, voice_info in voices_data.items() + ] + + logger.info("DesiVocal list voices complete count=%d", len(voices)) + + return DesiVocalListVoicesResponse( + success=True, + data=DesiVocalListVoicesData(voices=voices), + ) + except Exception as e: + logger.exception("DesiVocal list voices failed") + return DesiVocalListVoicesResponse( + success=False, error=f"DesiVocal list voices failed: {str(e)}" + ) diff --git a/src/tools/audio/eleven_labs.py b/src/tools/audio/eleven_labs.py new file mode 100644 index 0000000..ae88dbb --- /dev/null +++ b/src/tools/audio/eleven_labs.py @@ -0,0 +1,141 @@ +"""ElevenLabs text-to-speech and voice listing tools.""" + +from __future__ import annotations + +import base64 +import logging +from io import BytesIO + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.audio.schemas import ( + ElevenLabsListVoicesData, + ElevenLabsListVoicesResponse, + ElevenLabsTTSData, + ElevenLabsTTSResponse, + ElevenLabsVoice, +) + +try: + from elevenlabs import ElevenLabs +except ImportError as err: + raise ImportError( + "elevenlabs is required for ElevenLabs tools. " + "Install with: pip install elevenlabs" + ) from err + +logger = logging.getLogger("humcp.tools.eleven_labs") + + +@tool() +async def elevenlabs_text_to_speech( + text: str, + voice_id: str = "JBFqnCBsd6RMkjVDRZzb", + model_id: str = "eleven_multilingual_v2", + output_format: str = "mp3_44100_64", +) -> ElevenLabsTTSResponse: + """Convert text to speech using ElevenLabs API. + + Generates audio from text using a specified voice and model. + Returns the audio as base64-encoded content. + + Args: + text: The text to convert to speech. + voice_id: ElevenLabs voice ID to use (default: George). + model_id: Model to use for generation (default: eleven_multilingual_v2). + output_format: Audio output format (e.g., mp3_44100_64, mp3_44100_128). + + Returns: + Success flag with base64-encoded audio data or error message. + """ + try: + api_key = await resolve_credential("ELEVEN_LABS_API_KEY") + if not api_key: + return ElevenLabsTTSResponse( + success=False, + error="ElevenLabs API not configured. Set ELEVEN_LABS_API_KEY.", + ) + + logger.info( + "ElevenLabs TTS start voice_id=%s model_id=%s format=%s", + voice_id, + model_id, + output_format, + ) + + client = ElevenLabs(api_key=api_key) + audio_generator = client.text_to_speech.convert( + text=text, + voice_id=voice_id, + model_id=model_id, + output_format=output_format, + ) + + audio_bytes = BytesIO() + for chunk in audio_generator: + audio_bytes.write(chunk) + audio_bytes.seek(0) + audio_data = audio_bytes.read() + + encoded_audio = base64.b64encode(audio_data).decode("utf-8") + + logger.info("ElevenLabs TTS complete size=%d bytes", len(audio_data)) + + return ElevenLabsTTSResponse( + success=True, + data=ElevenLabsTTSData( + audio_base64=encoded_audio, + format=output_format.split("_")[0], + voice_id=voice_id, + model_id=model_id, + ), + ) + except Exception as e: + logger.exception("ElevenLabs TTS failed") + return ElevenLabsTTSResponse( + success=False, error=f"ElevenLabs TTS failed: {str(e)}" + ) + + +@tool() +async def elevenlabs_list_voices() -> ElevenLabsListVoicesResponse: + """List all available voices from ElevenLabs. + + Returns a list of voices with their IDs, names, and descriptions. + + Returns: + Success flag with list of available voices or error message. + """ + try: + api_key = await resolve_credential("ELEVEN_LABS_API_KEY") + if not api_key: + return ElevenLabsListVoicesResponse( + success=False, + error="ElevenLabs API not configured. Set ELEVEN_LABS_API_KEY.", + ) + + logger.info("ElevenLabs list voices start") + + client = ElevenLabs(api_key=api_key) + voices_response = client.voices.get_all() + + voices = [ + ElevenLabsVoice( + voice_id=voice.voice_id, + name=voice.name, + description=voice.description, + ) + for voice in voices_response.voices + ] + + logger.info("ElevenLabs list voices complete count=%d", len(voices)) + + return ElevenLabsListVoicesResponse( + success=True, + data=ElevenLabsListVoicesData(voices=voices), + ) + except Exception as e: + logger.exception("ElevenLabs list voices failed") + return ElevenLabsListVoicesResponse( + success=False, error=f"ElevenLabs list voices failed: {str(e)}" + ) diff --git a/src/tools/audio/mlx_transcribe.py b/src/tools/audio/mlx_transcribe.py new file mode 100644 index 0000000..f5f3f80 --- /dev/null +++ b/src/tools/audio/mlx_transcribe.py @@ -0,0 +1,103 @@ +"""MLX Whisper local audio transcription tool. + +Requirements: + - ffmpeg: Required for audio processing + macOS: brew install ffmpeg + Ubuntu: apt-get install ffmpeg + - mlx-whisper: Install via pip + pip install mlx-whisper + +Optimized for Apple Silicon processors. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from src.humcp.decorator import tool +from src.tools.audio.schemas import ( + MLXTranscribeData, + MLXTranscribeResponse, +) + +try: + import mlx_whisper +except ImportError as err: + raise ImportError( + "mlx-whisper is required for MLX transcription tools. " + "Install with: pip install mlx-whisper" + ) from err + +logger = logging.getLogger("humcp.tools.mlx_transcribe") + + +@tool() +async def mlx_transcribe( + audio_path: str, + model: str = "mlx-community/whisper-large-v3-turbo", + language: str | None = None, +) -> MLXTranscribeResponse: + """Transcribe audio to text using Apple's MLX Whisper model. + + Uses the MLX framework for fast, local transcription on Apple Silicon. + Supports various audio formats (mp3, wav, m4a, flac, etc.). + No API key required -- runs entirely on-device. + + Args: + audio_path: Path to the audio file to transcribe. + model: HuggingFace model repo for MLX Whisper + (default: mlx-community/whisper-large-v3-turbo). + language: Optional language code (e.g., 'en', 'es', 'fr'). + Auto-detected if not specified. + + Returns: + Success flag with transcribed text or error message. + """ + try: + resolved_path = Path(audio_path).resolve() + if not resolved_path.exists(): + return MLXTranscribeResponse( + success=False, + error=f"Audio file not found: {audio_path}", + ) + + logger.info( + "MLX transcribe start path=%s model=%s language=%s", + resolved_path, + model, + language, + ) + + transcription_kwargs: dict = { + "path_or_hf_repo": model, + } + if language is not None: + transcription_kwargs["language"] = language + + transcription = mlx_whisper.transcribe( + str(resolved_path), **transcription_kwargs + ) + text = transcription.get("text", "") + detected_language = transcription.get("language", language) + + logger.info( + "MLX transcribe complete chars=%d language=%s", + len(text), + detected_language, + ) + + return MLXTranscribeResponse( + success=True, + data=MLXTranscribeData( + text=text, + audio_path=str(resolved_path), + model=model, + language=detected_language, + ), + ) + except Exception as e: + logger.exception("MLX transcribe failed") + return MLXTranscribeResponse( + success=False, error=f"MLX transcription failed: {str(e)}" + ) diff --git a/src/tools/audio/schemas.py b/src/tools/audio/schemas.py new file mode 100644 index 0000000..8b68d88 --- /dev/null +++ b/src/tools/audio/schemas.py @@ -0,0 +1,315 @@ +"""Pydantic output schemas for audio tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# ElevenLabs Schemas +# ============================================================================= + + +class ElevenLabsVoice(BaseModel): + """A single voice from ElevenLabs.""" + + voice_id: str = Field(..., description="Unique voice identifier") + name: str = Field(..., description="Voice name") + description: str | None = Field(None, description="Voice description") + + +class ElevenLabsListVoicesData(BaseModel): + """Output data for elevenlabs_list_voices tool.""" + + voices: list[ElevenLabsVoice] = Field( + default_factory=list, description="List of available voices" + ) + + +class ElevenLabsTTSData(BaseModel): + """Output data for elevenlabs_text_to_speech tool.""" + + audio_url: str | None = Field( + default=None, description="URL or path of the generated audio file" + ) + audio_base64: str | None = Field( + default=None, description="Base64-encoded audio content" + ) + format: str = Field("mp3", description="Audio output format") + voice_id: str = Field(..., description="Voice ID used for generation") + model_id: str = Field(..., description="Model ID used for generation") + + +# ============================================================================= +# Cartesia Schemas +# ============================================================================= + + +class CartesiaTTSData(BaseModel): + """Output data for cartesia_text_to_speech tool.""" + + audio_base64: str | None = Field(None, description="Base64-encoded audio content") + format: str = Field("mp3", description="Audio output format") + voice_id: str = Field(..., description="Voice ID used for generation") + model_id: str = Field(..., description="Model ID used for generation") + + +# ============================================================================= +# DesiVocal Schemas +# ============================================================================= + + +class DesiVocalVoice(BaseModel): + """A single voice from DesiVocal.""" + + voice_id: str = Field(..., description="Unique voice identifier") + name: str = Field(..., description="Voice name") + gender: str | None = Field(None, description="Voice gender") + voice_type: str | None = Field(None, description="Voice type") + language: str | None = Field(None, description="Supported languages") + preview_url: str | None = Field(None, description="Preview audio URL") + + +class DesiVocalListVoicesData(BaseModel): + """Output data for desi_vocal_list_voices tool.""" + + voices: list[DesiVocalVoice] = Field( + default_factory=list, description="List of available voices" + ) + + +class DesiVocalTTSData(BaseModel): + """Output data for desi_vocal_tts tool.""" + + audio_url: str = Field(..., description="URL of the generated audio file") + voice_id: str = Field(..., description="Voice ID used for generation") + + +# ============================================================================= +# MLX Transcribe Schemas +# ============================================================================= + + +class MLXTranscribeData(BaseModel): + """Output data for mlx_transcribe tool.""" + + text: str = Field(..., description="Transcribed text from the audio file") + audio_path: str = Field( + ..., description="Path of the audio file that was transcribed" + ) + model: str = Field(..., description="Model used for transcription") + language: str | None = Field(None, description="Detected or specified language") + + +# ============================================================================= +# Spotify Schemas +# ============================================================================= + + +class SpotifyTrack(BaseModel): + """A single track from Spotify.""" + + track_id: str = Field(..., description="Spotify track ID") + name: str = Field(..., description="Track name") + artists: list[str] = Field(default_factory=list, description="List of artist names") + album: str | None = Field(None, description="Album name") + uri: str = Field(..., description="Spotify URI") + preview_url: str | None = Field(None, description="URL for 30-second preview") + popularity: int | None = Field(None, description="Popularity score (0-100)") + duration_ms: int | None = Field(None, description="Track duration in milliseconds") + + +class SpotifySearchData(BaseModel): + """Output data for spotify_search tool.""" + + query: str = Field(..., description="The search query that was executed") + search_type: str = Field(..., description="Type of search performed") + tracks: list[SpotifyTrack] = Field( + default_factory=list, description="List of matching tracks" + ) + + +class SpotifyTrackData(BaseModel): + """Output data for spotify_get_track tool.""" + + track: SpotifyTrack = Field(..., description="Track details") + + +class SpotifyPlaylistTrack(BaseModel): + """A track within a Spotify playlist.""" + + track_id: str = Field(..., description="Spotify track ID") + name: str = Field(..., description="Track name") + artists: list[str] = Field(default_factory=list, description="List of artist names") + uri: str = Field(..., description="Spotify URI") + + +class SpotifyPlaylistData(BaseModel): + """Output data for spotify_get_playlist tool.""" + + playlist_id: str = Field(..., description="Spotify playlist ID") + name: str = Field(..., description="Playlist name") + description: str | None = Field(None, description="Playlist description") + owner: str | None = Field(None, description="Playlist owner display name") + public: bool | None = Field(None, description="Whether the playlist is public") + url: str | None = Field(None, description="Spotify URL for the playlist") + tracks: list[SpotifyPlaylistTrack] = Field( + default_factory=list, description="List of tracks in the playlist" + ) + + +# ============================================================================= +# Cartesia List Voices Schemas +# ============================================================================= + + +class CartesiaVoice(BaseModel): + """A single voice from Cartesia.""" + + voice_id: str = Field(..., description="Unique voice identifier") + name: str = Field(..., description="Voice name") + description: str | None = Field(None, description="Voice description") + language: str | None = Field(None, description="Voice language") + is_public: bool | None = Field( + None, description="Whether the voice is publicly available" + ) + + +class CartesiaListVoicesData(BaseModel): + """Output data for cartesia_list_voices tool.""" + + voices: list[CartesiaVoice] = Field( + default_factory=list, description="List of available voices" + ) + + +# ============================================================================= +# Spotify Artist Schemas +# ============================================================================= + + +class SpotifyArtist(BaseModel): + """An artist from Spotify.""" + + artist_id: str = Field(..., description="Spotify artist ID") + name: str = Field(..., description="Artist name") + genres: list[str] = Field(default_factory=list, description="List of genres") + popularity: int | None = Field(None, description="Popularity score (0-100)") + followers: int | None = Field(None, description="Number of followers") + uri: str = Field(..., description="Spotify URI") + image_url: str | None = Field(None, description="Artist image URL") + external_url: str | None = Field(None, description="Spotify external URL") + + +class SpotifyArtistData(BaseModel): + """Output data for spotify_get_artist tool.""" + + artist: SpotifyArtist = Field(..., description="Artist details") + + +# ============================================================================= +# Spotify New Releases Schemas +# ============================================================================= + + +class SpotifyAlbum(BaseModel): + """An album from Spotify.""" + + album_id: str = Field(..., description="Spotify album ID") + name: str = Field(..., description="Album name") + artists: list[str] = Field(default_factory=list, description="List of artist names") + release_date: str | None = Field(None, description="Release date") + total_tracks: int | None = Field(None, description="Total number of tracks") + album_type: str | None = Field( + None, description="Album type (album, single, compilation)" + ) + uri: str = Field(..., description="Spotify URI") + image_url: str | None = Field(None, description="Album cover image URL") + external_url: str | None = Field(None, description="Spotify external URL") + + +class SpotifyNewReleasesData(BaseModel): + """Output data for spotify_get_new_releases tool.""" + + albums: list[SpotifyAlbum] = Field( + default_factory=list, description="List of new album releases" + ) + total: int | None = Field( + None, description="Total number of new releases available" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ElevenLabsListVoicesResponse(ToolResponse[ElevenLabsListVoicesData]): + """Response schema for elevenlabs_list_voices tool.""" + + pass + + +class ElevenLabsTTSResponse(ToolResponse[ElevenLabsTTSData]): + """Response schema for elevenlabs_text_to_speech tool.""" + + pass + + +class CartesiaTTSResponse(ToolResponse[CartesiaTTSData]): + """Response schema for cartesia_text_to_speech tool.""" + + pass + + +class DesiVocalListVoicesResponse(ToolResponse[DesiVocalListVoicesData]): + """Response schema for desi_vocal_list_voices tool.""" + + pass + + +class DesiVocalTTSResponse(ToolResponse[DesiVocalTTSData]): + """Response schema for desi_vocal_tts tool.""" + + pass + + +class MLXTranscribeResponse(ToolResponse[MLXTranscribeData]): + """Response schema for mlx_transcribe tool.""" + + pass + + +class SpotifySearchResponse(ToolResponse[SpotifySearchData]): + """Response schema for spotify_search tool.""" + + pass + + +class SpotifyTrackResponse(ToolResponse[SpotifyTrackData]): + """Response schema for spotify_get_track tool.""" + + pass + + +class SpotifyPlaylistResponse(ToolResponse[SpotifyPlaylistData]): + """Response schema for spotify_get_playlist tool.""" + + pass + + +class CartesiaListVoicesResponse(ToolResponse[CartesiaListVoicesData]): + """Response schema for cartesia_list_voices tool.""" + + pass + + +class SpotifyArtistResponse(ToolResponse[SpotifyArtistData]): + """Response schema for spotify_get_artist tool.""" + + pass + + +class SpotifyNewReleasesResponse(ToolResponse[SpotifyNewReleasesData]): + """Response schema for spotify_get_new_releases tool.""" + + pass diff --git a/src/tools/audio/spotify.py b/src/tools/audio/spotify.py new file mode 100644 index 0000000..01c08c7 --- /dev/null +++ b/src/tools/audio/spotify.py @@ -0,0 +1,386 @@ +"""Spotify Web API tools for searching tracks, getting track details, and playlists.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.audio.schemas import ( + SpotifyAlbum, + SpotifyArtist, + SpotifyArtistData, + SpotifyArtistResponse, + SpotifyNewReleasesData, + SpotifyNewReleasesResponse, + SpotifyPlaylistData, + SpotifyPlaylistResponse, + SpotifyPlaylistTrack, + SpotifySearchData, + SpotifySearchResponse, + SpotifyTrack, + SpotifyTrackData, + SpotifyTrackResponse, +) + +logger = logging.getLogger("humcp.tools.spotify") + +SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token" +SPOTIFY_API_BASE = "https://api.spotify.com/v1" + + +async def _get_spotify_token() -> str | None: + """Obtain a Spotify access token via Client Credentials flow.""" + client_id = await resolve_credential("SPOTIFY_CLIENT_ID") + client_secret = await resolve_credential("SPOTIFY_CLIENT_SECRET") + if not client_id or not client_secret: + return None + + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + SPOTIFY_TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=(client_id, client_secret), + ) + response.raise_for_status() + return response.json()["access_token"] + + +async def _spotify_request( + endpoint: str, + token: str, + params: dict | None = None, +) -> dict: + """Make an authenticated GET request to the Spotify Web API.""" + url = f"{SPOTIFY_API_BASE}/{endpoint}" + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + +@tool() +async def spotify_search( + query: str, + search_type: str = "track", + limit: int = 10, + market: str = "US", +) -> SpotifySearchResponse: + """Search for tracks on Spotify. + + Find songs by name, artist, album, or any combination of keywords. + + Args: + query: Search query (e.g., 'Bohemian Rhapsody', 'Coldplay Paradise'). + search_type: Type of search -- 'track' (default). + limit: Maximum number of results to return (default: 10, max: 50). + market: Country code for market (default: 'US'). + + Returns: + Success flag with list of matching tracks or error message. + """ + try: + token = await _get_spotify_token() + if not token: + return SpotifySearchResponse( + success=False, + error="Spotify API not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.", + ) + + logger.info( + "Spotify search start query=%s type=%s limit=%d", + query, + search_type, + limit, + ) + + params = { + "q": query, + "type": search_type, + "limit": min(limit, 50), + "market": market, + } + + result = await _spotify_request("search", token, params=params) + + tracks_data = result.get("tracks", {}).get("items", []) + tracks = [ + SpotifyTrack( + track_id=track["id"], + name=track["name"], + artists=[artist["name"] for artist in track["artists"]], + album=track["album"]["name"], + uri=track["uri"], + preview_url=track.get("preview_url"), + popularity=track.get("popularity"), + duration_ms=track.get("duration_ms"), + ) + for track in tracks_data + ] + + logger.info("Spotify search complete results=%d", len(tracks)) + + return SpotifySearchResponse( + success=True, + data=SpotifySearchData( + query=query, + search_type=search_type, + tracks=tracks, + ), + ) + except Exception as e: + logger.exception("Spotify search failed") + return SpotifySearchResponse( + success=False, error=f"Spotify search failed: {str(e)}" + ) + + +@tool() +async def spotify_get_track( + track_id: str, + market: str = "US", +) -> SpotifyTrackResponse: + """Get details of a specific Spotify track by its ID. + + Args: + track_id: The Spotify track ID. + market: Country code for market (default: 'US'). + + Returns: + Success flag with track details or error message. + """ + try: + token = await _get_spotify_token() + if not token: + return SpotifyTrackResponse( + success=False, + error="Spotify API not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.", + ) + + logger.info("Spotify get track start track_id=%s", track_id) + + result = await _spotify_request( + f"tracks/{track_id}", token, params={"market": market} + ) + + track = SpotifyTrack( + track_id=result["id"], + name=result["name"], + artists=[artist["name"] for artist in result["artists"]], + album=result["album"]["name"], + uri=result["uri"], + preview_url=result.get("preview_url"), + popularity=result.get("popularity"), + duration_ms=result.get("duration_ms"), + ) + + logger.info("Spotify get track complete name=%s", track.name) + + return SpotifyTrackResponse( + success=True, + data=SpotifyTrackData(track=track), + ) + except Exception as e: + logger.exception("Spotify get track failed") + return SpotifyTrackResponse( + success=False, error=f"Spotify get track failed: {str(e)}" + ) + + +@tool() +async def spotify_get_playlist( + playlist_id: str, + market: str = "US", +) -> SpotifyPlaylistResponse: + """Get details and tracks of a Spotify playlist by its ID. + + Args: + playlist_id: The Spotify playlist ID. + market: Country code for market (default: 'US'). + + Returns: + Success flag with playlist details and tracks or error message. + """ + try: + token = await _get_spotify_token() + if not token: + return SpotifyPlaylistResponse( + success=False, + error="Spotify API not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.", + ) + + logger.info("Spotify get playlist start playlist_id=%s", playlist_id) + + params = { + "fields": "id,name,description,public,owner(display_name),external_urls,tracks.items(track(id,name,artists(name),uri))", + "market": market, + } + + result = await _spotify_request( + f"playlists/{playlist_id}", token, params=params + ) + + tracks = [ + SpotifyPlaylistTrack( + track_id=item["track"]["id"], + name=item["track"]["name"], + artists=[a["name"] for a in item["track"]["artists"]], + uri=item["track"]["uri"], + ) + for item in result.get("tracks", {}).get("items", []) + if item.get("track") + ] + + logger.info( + "Spotify get playlist complete name=%s tracks=%d", + result.get("name"), + len(tracks), + ) + + return SpotifyPlaylistResponse( + success=True, + data=SpotifyPlaylistData( + playlist_id=result["id"], + name=result["name"], + description=result.get("description"), + owner=result.get("owner", {}).get("display_name"), + public=result.get("public"), + url=result.get("external_urls", {}).get("spotify"), + tracks=tracks, + ), + ) + except Exception as e: + logger.exception("Spotify get playlist failed") + return SpotifyPlaylistResponse( + success=False, error=f"Spotify get playlist failed: {str(e)}" + ) + + +@tool() +async def spotify_get_artist( + artist_id: str, +) -> SpotifyArtistResponse: + """Get details of a Spotify artist by their ID. + + Retrieves artist information including name, genres, popularity, + follower count, and image. + + Args: + artist_id: The Spotify artist ID. + + Returns: + Success flag with artist details or error message. + """ + try: + token = await _get_spotify_token() + if not token: + return SpotifyArtistResponse( + success=False, + error="Spotify API not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.", + ) + + logger.info("Spotify get artist start artist_id=%s", artist_id) + + result = await _spotify_request(f"artists/{artist_id}", token) + + images = result.get("images", []) + image_url = images[0]["url"] if images else None + + artist = SpotifyArtist( + artist_id=result["id"], + name=result["name"], + genres=result.get("genres", []), + popularity=result.get("popularity"), + followers=result.get("followers", {}).get("total"), + uri=result["uri"], + image_url=image_url, + external_url=result.get("external_urls", {}).get("spotify"), + ) + + logger.info("Spotify get artist complete name=%s", artist.name) + + return SpotifyArtistResponse( + success=True, + data=SpotifyArtistData(artist=artist), + ) + except Exception as e: + logger.exception("Spotify get artist failed") + return SpotifyArtistResponse( + success=False, error=f"Spotify get artist failed: {str(e)}" + ) + + +@tool() +async def spotify_get_new_releases( + country: str = "US", + limit: int = 20, +) -> SpotifyNewReleasesResponse: + """Get a list of new album releases on Spotify. + + Retrieves the latest album releases available in a given market. + + Args: + country: ISO 3166-1 alpha-2 country code (default: 'US'). + limit: Maximum number of releases to return (default: 20, max: 50). + + Returns: + Success flag with list of new album releases or error message. + """ + try: + token = await _get_spotify_token() + if not token: + return SpotifyNewReleasesResponse( + success=False, + error="Spotify API not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.", + ) + + logger.info( + "Spotify get new releases start country=%s limit=%d", + country, + limit, + ) + + params = { + "country": country, + "limit": min(limit, 50), + } + + result = await _spotify_request("browse/new-releases", token, params=params) + + albums_data = result.get("albums", {}) + items = albums_data.get("items", []) + + albums = [ + SpotifyAlbum( + album_id=album["id"], + name=album["name"], + artists=[a["name"] for a in album.get("artists", [])], + release_date=album.get("release_date"), + total_tracks=album.get("total_tracks"), + album_type=album.get("album_type"), + uri=album["uri"], + image_url=(album["images"][0]["url"] if album.get("images") else None), + external_url=album.get("external_urls", {}).get("spotify"), + ) + for album in items + ] + + logger.info("Spotify get new releases complete count=%d", len(albums)) + + return SpotifyNewReleasesResponse( + success=True, + data=SpotifyNewReleasesData( + albums=albums, + total=albums_data.get("total"), + ), + ) + except Exception as e: + logger.exception("Spotify get new releases failed") + return SpotifyNewReleasesResponse( + success=False, + error=f"Spotify get new releases failed: {str(e)}", + ) diff --git a/src/tools/builder/SKILL.md b/src/tools/builder/SKILL.md new file mode 100644 index 0000000..035d4a4 --- /dev/null +++ b/src/tools/builder/SKILL.md @@ -0,0 +1,173 @@ +--- +name: custom-tool-builder +description: Create custom tools with sandboxed Python code. Use when you need to define new tools dynamically without modifying the codebase. +--- + +# Tool Builder + +Create and manage custom tools with sandboxed Python execution. + +## Overview + +The tool builder allows you to create new tools by providing Python code that runs in a secure sandbox. Custom tools can be enabled to become first-class MCP/REST tools. + +## Creating a Custom Tool + +```python +result = await tool_builder_create( + name="text_processor", + description="Process text with various transformations", + parameters={ + "text": {"type": "string", "description": "Input text"}, + "uppercase": {"type": "boolean", "default": False} + }, + code=''' +def execute(text: str, uppercase: bool = False) -> dict: + result = text.upper() if uppercase else text.lower() + return {"success": True, "data": {"result": result}} +''', + category="custom" +) +``` + +### Code Requirements + +1. Must define an `execute` function +2. Function must return a dict with `success` and `data`/`error` keys +3. Code runs in a sandboxed environment with limited capabilities + +## Testing a Custom Tool + +Before enabling, test your tool: + +```python +result = await tool_builder_test( + name="text_processor", + params={"text": "Hello World", "uppercase": True} +) +# Returns: {"success": True, "data": {"tool_name": "text_processor", "result": {...}}} +``` + +## Enabling/Disabling Tools + +Tools are created disabled. Enable to make them available via MCP/REST: + +```python +# Enable - registers with MCP +await tool_builder_enable(name="text_processor") + +# Disable - unregisters from MCP +await tool_builder_disable(name="text_processor") +``` + +## Managing Custom Tools + +```python +# List all custom tools +result = await tool_builder_list() + +# Get tool details +result = await tool_builder_get(name="text_processor") + +# Update tool code or description +result = await tool_builder_update( + name="text_processor", + description="Updated description", + code="def execute(): return {'success': True, 'data': {}}" +) + +# Delete a tool +result = await tool_builder_delete(name="text_processor") +``` + +## Sandbox Environment + +### Allowed + +- Basic Python: variables, functions, loops, conditionals +- Type constructors: `list`, `dict`, `set`, `str`, `int`, `float`, etc. +- Utility functions: `len`, `range`, `sorted`, `min`, `max`, `sum`, etc. +- Allowed imports: `json`, `re`, `math`, `datetime` + +### Blocked + +- File system access: `open`, `os`, `pathlib` +- Network access: `socket`, `requests`, `httpx` +- System calls: `subprocess`, `os.system` +- Dangerous builtins: `exec`, `eval`, `compile`, `__import__` +- Module imports (except allowlist) + +### Execution Limits + +- **Timeout**: 60 seconds maximum execution time +- **Memory**: No hard limit (Python process limit applies) + +## Example: JSON Transformer + +```python +await tool_builder_create( + name="json_transform", + description="Transform JSON data by extracting specific fields", + parameters={ + "data": {"type": "object", "description": "Input JSON object"}, + "fields": {"type": "array", "description": "Fields to extract"} + }, + code=''' +def execute(data: dict, fields: list) -> dict: + result = {field: data.get(field) for field in fields if field in data} + return {"success": True, "data": result} +''' +) +``` + +## Example: Text Statistics + +```python +await tool_builder_create( + name="text_stats", + description="Calculate statistics about text", + parameters={ + "text": {"type": "string", "description": "Input text"} + }, + code=''' +def execute(text: str) -> dict: + words = text.split() + return { + "success": True, + "data": { + "char_count": len(text), + "word_count": len(words), + "line_count": text.count("\\n") + 1, + "avg_word_length": sum(len(w) for w in words) / len(words) if words else 0 + } + } +''' +) +``` + +## Response Format + +All tools return: +```json +{ + "success": true, + "data": { + "name": "my_tool", + "description": "...", + "code": "...", + "parameters": {...}, + "category": "custom", + "enabled": false, + "created_at": "2026-01-03T...", + "updated_at": "2026-01-03T..." + } +} +``` + +On error: +```json +{ + "success": false, + "error": "Error description" +} +``` diff --git a/src/tools/builder/__init__.py b/src/tools/builder/__init__.py new file mode 100644 index 0000000..e492259 --- /dev/null +++ b/src/tools/builder/__init__.py @@ -0,0 +1 @@ +"""Tool builder for creating custom tools with sandboxed Python execution.""" diff --git a/src/tools/builder/manager.py b/src/tools/builder/manager.py new file mode 100644 index 0000000..5c52be3 --- /dev/null +++ b/src/tools/builder/manager.py @@ -0,0 +1,181 @@ +"""Manager for dynamic custom tool registration with MCP and REST.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from src.tools.builder.sandbox import compile_code, execute_sandboxed +from src.tools.builder.storage import CustomToolDefinition, get_tool_storage + +if TYPE_CHECKING: + from fastapi import FastAPI + from fastmcp import FastMCP + +logger = logging.getLogger("humcp.builder.manager") + + +class CustomToolManager: + """Manages dynamic registration of custom tools with MCP and REST. + + This manager allows custom tools to be registered and unregistered + at runtime, making them available as first-class MCP/REST tools. + """ + + _instance: CustomToolManager | None = None + _initialized: bool = False + + def __new__(cls) -> CustomToolManager: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._initialized = False + return cls._instance + + def __init__(self) -> None: + if CustomToolManager._initialized: + return + self._mcp: FastMCP | None = None + self._app: FastAPI | None = None + self._registered_tools: dict[str, Any] = {} + self._compiled_cache: dict[str, Any] = {} + CustomToolManager._initialized = True + + @classmethod + def reset(cls) -> None: + """Reset the singleton (for testing).""" + cls._instance = None + cls._initialized = False + + def initialize(self, mcp: FastMCP, app: FastAPI) -> None: + """Initialize with MCP and FastAPI instances.""" + self._mcp = mcp + self._app = app + logger.info("CustomToolManager initialized") + + def is_initialized(self) -> bool: + """Check if manager is initialized.""" + return self._mcp is not None and self._app is not None + + async def register_tool(self, tool_def: CustomToolDefinition) -> bool: + """Register a custom tool with MCP. + + Args: + tool_def: The custom tool definition. + + Returns: + True if registration succeeded. + """ + if not self.is_initialized(): + logger.warning("CustomToolManager not initialized") + return False + + if tool_def.name in self._registered_tools: + logger.warning("Tool already registered name=%s", tool_def.name) + return False + + try: + # Compile the code + compiled = compile_code(tool_def.code) + self._compiled_cache[tool_def.name] = compiled + + # Create wrapper function + async def tool_wrapper( + _name: str = tool_def.name, + _compiled: Any = compiled, + **kwargs: Any, + ) -> dict[str, Any]: + """Dynamic wrapper for custom tool execution.""" + try: + result = await execute_sandboxed( + compiled_code=_compiled, + function_name="execute", + params=kwargs, + ) + return result + except Exception as e: + return {"success": False, "error": str(e)} + + # Set function metadata for FastMCP + tool_wrapper.__name__ = tool_def.name + tool_wrapper.__doc__ = tool_def.description + + # Register with FastMCP + if self._mcp: + self._mcp.tool(name=tool_def.name)(tool_wrapper) + self._registered_tools[tool_def.name] = tool_wrapper + logger.info("Custom tool registered with MCP name=%s", tool_def.name) + + return True + + except Exception: + logger.exception("Failed to register custom tool name=%s", tool_def.name) + return False + + async def unregister_tool(self, name: str) -> bool: + """Unregister a custom tool from MCP. + + Note: FastMCP doesn't support removing tools at runtime, + so we just remove from our tracking. + + Args: + name: Name of the tool to unregister. + + Returns: + True if unregistration succeeded. + """ + if name not in self._registered_tools: + return False + + del self._registered_tools[name] + if name in self._compiled_cache: + del self._compiled_cache[name] + + logger.info("Custom tool unregistered name=%s", name) + return True + + def is_registered(self, name: str) -> bool: + """Check if a tool is registered.""" + return name in self._registered_tools + + def list_registered(self) -> list[str]: + """List all registered custom tool names.""" + return list(self._registered_tools.keys()) + + async def sync_enabled_tools(self) -> int: + """Sync all enabled tools from storage to MCP. + + Call this during server startup to register all enabled custom tools. + + Returns: + Number of tools registered. + """ + storage = get_tool_storage() + tools = await storage.list_all() + + count = 0 + for tool_def in tools: + if tool_def.enabled and not self.is_registered(tool_def.name): + if await self.register_tool(tool_def): + count += 1 + + logger.info("Synced %d enabled custom tools", count) + return count + + +# Global manager instance +_manager: CustomToolManager | None = None + + +def get_custom_tool_manager() -> CustomToolManager: + """Get the global CustomToolManager instance.""" + global _manager + if _manager is None: + _manager = CustomToolManager() + return _manager + + +def reset_custom_tool_manager() -> None: + """Reset the global manager (for testing).""" + global _manager + CustomToolManager.reset() + _manager = None diff --git a/src/tools/builder/sandbox.py b/src/tools/builder/sandbox.py new file mode 100644 index 0000000..96d7ff9 --- /dev/null +++ b/src/tools/builder/sandbox.py @@ -0,0 +1,291 @@ +"""Sandboxed Python execution environment using RestrictedPython.""" + +from __future__ import annotations + +import asyncio +import builtins +import logging +import types +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from RestrictedPython import compile_restricted, safe_builtins +from RestrictedPython.Eval import default_guarded_getiter +from RestrictedPython.Guards import ( + guarded_iter_unpack_sequence, + safer_getattr, +) + +logger = logging.getLogger("humcp.builder.sandbox") + + +def _guarded_write(obj: Any) -> Any: + """Guard that prevents writing to restricted types.""" + if isinstance(obj, type | types.ModuleType): + raise AttributeError("Cannot modify restricted objects") + return obj + + +def _guarded_getitem(obj: Any, key: Any) -> Any: + """Guard that prevents item access on restricted types.""" + if isinstance(obj, type | types.ModuleType): + raise AttributeError("Cannot access restricted object items") + return obj[key] + + +# Execution timeout in seconds +EXECUTION_TIMEOUT = 60 + +# Thread pool for running sandboxed code +_executor = ThreadPoolExecutor(max_workers=4) + + +def _get_allowed_imports() -> dict[str, Any]: + """Get the allowed imports for sandboxed execution.""" + import datetime + import json + import math + import re + + return { + "json": json, + "re": re, + "math": math, + "datetime": datetime, + } + + +def _get_safe_builtins() -> dict[str, Any]: + """Get safe builtins for sandboxed execution.""" + builtins = dict(safe_builtins) + + # Add additional safe builtins + builtins.update( + { + # Type constructors + "list": list, + "dict": dict, + "set": set, + "tuple": tuple, + "frozenset": frozenset, + "str": str, + "int": int, + "float": float, + "bool": bool, + "bytes": bytes, + # Utility functions + "len": len, + "range": range, + "enumerate": enumerate, + "zip": zip, + "map": map, + "filter": filter, + "sorted": sorted, + "reversed": reversed, + "min": min, + "max": max, + "sum": sum, + "abs": abs, + "round": round, + "all": all, + "any": any, + "isinstance": isinstance, + "hasattr": hasattr, + "getattr": getattr, + # String methods + "chr": chr, + "ord": ord, + "repr": repr, + "format": format, + # Exceptions (for raising) + "Exception": Exception, + "ValueError": ValueError, + "TypeError": TypeError, + "KeyError": KeyError, + "IndexError": IndexError, + } + ) + + return builtins + + +def _get_restricted_globals() -> dict[str, Any]: + """Build the restricted globals for sandboxed execution.""" + restricted_globals: dict[str, Any] = { + "__builtins__": _get_safe_builtins(), + "_getattr_": safer_getattr, + "_getiter_": default_guarded_getiter, + "_iter_unpack_sequence_": guarded_iter_unpack_sequence, + # Allow print for debugging (captured) + "_print_": lambda *args, **kwargs: None, # Silently ignore prints + "_getitem_": _guarded_getitem, + "_write_": _guarded_write, + } + + # Add allowed imports + restricted_globals.update(_get_allowed_imports()) + + return restricted_globals + + +class SandboxError(Exception): + """Error during sandboxed execution.""" + + pass + + +class CompilationError(SandboxError): + """Error during code compilation.""" + + pass + + +class ExecutionError(SandboxError): + """Error during code execution.""" + + pass + + +class TimeoutError(SandboxError): + """Execution timed out.""" + + pass + + +def compile_code(code: str, filename: str = "") -> Any: + """Compile Python code in restricted mode. + + Args: + code: Python source code to compile. + filename: Filename for error messages. + + Returns: + Compiled code object. + + Raises: + CompilationError: If compilation fails. + """ + try: + # RestrictedPython 8.x returns the compiled code directly + # or raises an exception if compilation fails + compiled = compile_restricted(code, filename=filename, mode="exec") + return compiled + + except SyntaxError as e: + raise CompilationError(f"Syntax error: {e}") from e + except Exception as e: + raise CompilationError(f"Compilation failed: {e}") from e + + +def _execute_sync( + compiled_code: Any, + function_name: str, + params: dict[str, Any], +) -> dict[str, Any]: + """Execute compiled code synchronously (runs in thread pool).""" + # Create execution namespace + namespace: dict[str, Any] = {} + restricted_globals = _get_restricted_globals() + + # Execute the code to define the function + exec(compiled_code, restricted_globals, namespace) + + # Get the function + if function_name not in namespace: + raise ExecutionError( + f"Function '{function_name}' not found. Available: {list(namespace.keys())}" + ) + + func = namespace[function_name] + if not callable(func): + raise ExecutionError(f"'{function_name}' is not a function") + + # Execute the function + result = func(**params) + + # Validate result + if not isinstance(result, dict): + raise ExecutionError( + f"Function must return a dict, got {type(result).__name__}" + ) + + return result + + +async def execute_sandboxed( + compiled_code: Any, + function_name: str, + params: dict[str, Any], + timeout: float = EXECUTION_TIMEOUT, +) -> dict[str, Any]: + """Execute compiled code in a sandboxed environment. + + Args: + compiled_code: Compiled code object from compile_code(). + function_name: Name of the function to call. + params: Parameters to pass to the function. + timeout: Maximum execution time in seconds. + + Returns: + Result dictionary from the function. + + Raises: + ExecutionError: If execution fails. + TimeoutError: If execution times out. + """ + try: + result = await asyncio.wait_for( + asyncio.to_thread( + _execute_sync, + compiled_code, + function_name, + params, + ), + timeout=timeout, + ) + return result + + except builtins.TimeoutError as e: + raise TimeoutError(f"Execution timed out after {timeout} seconds") from e + except ExecutionError: + raise + except Exception as e: + raise ExecutionError(f"Execution failed: {e}") from e + + +def validate_tool_code(code: str) -> tuple[bool, str]: + """Validate tool code before saving. + + Checks: + - Code compiles successfully + - Contains an 'execute' function + - Function signature looks reasonable + + Args: + code: Python source code. + + Returns: + Tuple of (is_valid, error_message). + """ + try: + # Try to compile + compiled = compile_code(code) + + # Execute to check function exists + namespace: dict[str, Any] = {} + restricted_globals = _get_restricted_globals() + exec(compiled, restricted_globals, namespace) + + # Check for execute function + if "execute" not in namespace: + return False, "Code must define an 'execute' function" + + if not callable(namespace["execute"]): + return False, "'execute' must be a function" + + return True, "" + + except CompilationError as e: + return False, str(e) + except Exception as e: + return False, f"Validation failed: {e}" diff --git a/src/tools/builder/schemas.py b/src/tools/builder/schemas.py new file mode 100644 index 0000000..c0f261a --- /dev/null +++ b/src/tools/builder/schemas.py @@ -0,0 +1,93 @@ +"""Pydantic output schemas for tool builder tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Custom Tool Data Schemas +# ============================================================================= + + +class CustomToolData(BaseModel): + """Data representing a custom tool definition.""" + + name: str = Field(..., description="Tool name") + description: str = Field(..., description="Tool description") + code: str = Field(..., description="Python code for the tool") + parameters: dict[str, Any] = Field( + ..., description="JSON Schema for tool parameters" + ) + category: str = Field(..., description="Tool category") + enabled: bool = Field(..., description="Whether the tool is enabled") + created_at: str | None = Field(None, description="Creation timestamp") + updated_at: str | None = Field(None, description="Last update timestamp") + + +class ToolDeleteData(BaseModel): + """Output data for tool deletion.""" + + message: str = Field(..., description="Success message") + name: str = Field(..., description="Name of the deleted tool") + + +class ToolTestData(BaseModel): + """Output data for tool_builder_test.""" + + tool_name: str = Field(..., description="Name of the tested tool") + result: Any = Field(..., description="Execution result from the tool") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ToolBuilderCreateResponse(ToolResponse[CustomToolData]): + """Response schema for tool_builder_create tool.""" + + pass + + +class ToolBuilderListResponse(ToolResponse[list[CustomToolData]]): + """Response schema for tool_builder_list tool.""" + + pass + + +class ToolBuilderGetResponse(ToolResponse[CustomToolData]): + """Response schema for tool_builder_get tool.""" + + pass + + +class ToolBuilderDeleteResponse(ToolResponse[ToolDeleteData]): + """Response schema for tool_builder_delete tool.""" + + pass + + +class ToolBuilderUpdateResponse(ToolResponse[CustomToolData]): + """Response schema for tool_builder_update tool.""" + + pass + + +class ToolBuilderTestResponse(ToolResponse[ToolTestData]): + """Response schema for tool_builder_test tool.""" + + pass + + +class ToolBuilderEnableResponse(ToolResponse[CustomToolData]): + """Response schema for tool_builder_enable tool.""" + + pass + + +class ToolBuilderDisableResponse(ToolResponse[CustomToolData]): + """Response schema for tool_builder_disable tool.""" + + pass diff --git a/src/tools/builder/storage.py b/src/tools/builder/storage.py new file mode 100644 index 0000000..abda8d0 --- /dev/null +++ b/src/tools/builder/storage.py @@ -0,0 +1,155 @@ +"""Storage interface and implementations for custom tools.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +logger = logging.getLogger("humcp.builder.storage") + + +@dataclass +class CustomToolDefinition: + """Definition of a custom tool.""" + + name: str + description: str + code: str + parameters: dict[str, Any] + category: str = "custom" + enabled: bool = False + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "name": self.name, + "description": self.description, + "code": self.code, + "parameters": self.parameters, + "category": self.category, + "enabled": self.enabled, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + +class ToolStorage(ABC): + """Abstract interface for tool storage. + + Implement this interface for different storage backends: + - InMemoryToolStorage (default) + - SQLiteToolStorage (future) + - PostgresToolStorage (future) + """ + + @abstractmethod + async def save(self, tool: CustomToolDefinition) -> None: + """Save a tool definition.""" + ... + + @abstractmethod + async def get(self, name: str) -> CustomToolDefinition | None: + """Get a tool by name.""" + ... + + @abstractmethod + async def delete(self, name: str) -> bool: + """Delete a tool by name. Returns True if deleted.""" + ... + + @abstractmethod + async def list_all(self) -> list[CustomToolDefinition]: + """List all tools.""" + ... + + @abstractmethod + async def exists(self, name: str) -> bool: + """Check if a tool exists.""" + ... + + @abstractmethod + async def update(self, name: str, **kwargs: Any) -> CustomToolDefinition | None: + """Update a tool's fields. Returns updated tool or None if not found.""" + ... + + +class InMemoryToolStorage(ToolStorage): + """In-memory storage for custom tools. + + Tools are lost on server restart. Use for development/testing. + """ + + def __init__(self) -> None: + self._tools: dict[str, CustomToolDefinition] = {} + + async def save(self, tool: CustomToolDefinition) -> None: + """Save a tool definition.""" + self._tools[tool.name] = tool + logger.info("Tool saved name=%s", tool.name) + + async def get(self, name: str) -> CustomToolDefinition | None: + """Get a tool by name.""" + return self._tools.get(name) + + async def delete(self, name: str) -> bool: + """Delete a tool by name.""" + if name in self._tools: + del self._tools[name] + logger.info("Tool deleted name=%s", name) + return True + return False + + async def list_all(self) -> list[CustomToolDefinition]: + """List all tools.""" + return list(self._tools.values()) + + async def exists(self, name: str) -> bool: + """Check if a tool exists.""" + return name in self._tools + + async def update(self, name: str, **kwargs: Any) -> CustomToolDefinition | None: + """Update a tool's fields.""" + tool = self._tools.get(name) + if tool is None: + return None + + for key, value in kwargs.items(): + if hasattr(tool, key): + setattr(tool, key, value) + + tool.updated_at = datetime.now() + logger.info("Tool updated name=%s fields=%s", name, list(kwargs.keys())) + return tool + + def clear(self) -> None: + """Clear all tools (for testing).""" + self._tools.clear() + + +# Global storage instance +_storage: ToolStorage | None = None + + +def get_tool_storage() -> ToolStorage: + """Get the global tool storage instance.""" + global _storage + if _storage is None: + _storage = InMemoryToolStorage() + return _storage + + +def set_tool_storage(storage: ToolStorage) -> None: + """Set a custom tool storage implementation.""" + global _storage + _storage = storage + + +def reset_tool_storage() -> None: + """Reset the global storage (for testing).""" + global _storage + _storage = None diff --git a/src/tools/builder/tools.py b/src/tools/builder/tools.py new file mode 100644 index 0000000..b502289 --- /dev/null +++ b/src/tools/builder/tools.py @@ -0,0 +1,421 @@ +"""Tool builder tools for creating and managing custom tools.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.builder.manager import get_custom_tool_manager +from src.tools.builder.sandbox import ( + CompilationError, + ExecutionError, + TimeoutError, + compile_code, + execute_sandboxed, + validate_tool_code, +) +from src.tools.builder.schemas import ( + CustomToolData, + ToolBuilderCreateResponse, + ToolBuilderDeleteResponse, + ToolBuilderDisableResponse, + ToolBuilderEnableResponse, + ToolBuilderGetResponse, + ToolBuilderListResponse, + ToolBuilderTestResponse, + ToolBuilderUpdateResponse, + ToolDeleteData, + ToolTestData, +) +from src.tools.builder.storage import ( + CustomToolDefinition, + get_tool_storage, +) + +logger = logging.getLogger("humcp.builder.tools") + + +def _to_custom_tool_data(tool_def: CustomToolDefinition) -> CustomToolData: + """Convert CustomToolDefinition to CustomToolData.""" + return CustomToolData( + name=tool_def.name, + description=tool_def.description, + code=tool_def.code, + parameters=tool_def.parameters, + category=tool_def.category, + enabled=tool_def.enabled, + created_at=tool_def.created_at.isoformat(), + updated_at=tool_def.updated_at.isoformat(), + ) + + +@tool() +async def tool_builder_create( + name: str, + description: str, + code: str, + parameters: dict[str, Any] | None = None, + category: str = "custom", +) -> ToolBuilderCreateResponse: + """Create a new custom tool with sandboxed Python code. + + The code must define an 'execute' function that takes parameters + and returns a dict with 'success' and 'data' or 'error' keys. + + Args: + name: Unique name for the tool. + description: Description of what the tool does. + code: Python code defining an 'execute' function. + parameters: JSON Schema for tool parameters. + category: Tool category (default: "custom"). + + Returns: + Created tool details. + + Example: + tool_builder_create( + name="text_upper", + description="Convert text to uppercase", + parameters={"text": {"type": "string", "description": "Input text"}}, + code=''' + def execute(text: str) -> dict: + return {"success": True, "data": {"result": text.upper()}} + ''' + ) + """ + try: + await require_auth() + storage = get_tool_storage() + + # Check if tool already exists + if await storage.exists(name): + return ToolBuilderCreateResponse( + success=False, + error=f"Tool '{name}' already exists. Use tool_builder_update or delete first.", + ) + + # Validate code + is_valid, error = validate_tool_code(code) + if not is_valid: + return ToolBuilderCreateResponse( + success=False, error=f"Invalid code: {error}" + ) + + # Create tool definition + tool_def = CustomToolDefinition( + name=name, + description=description, + code=code, + parameters=parameters or {}, + category=category, + enabled=False, + ) + + # Save to storage + await storage.save(tool_def) + + logger.info("Custom tool created name=%s", name) + return ToolBuilderCreateResponse( + success=True, data=_to_custom_tool_data(tool_def) + ) + + except Exception as e: + logger.exception("Failed to create custom tool") + return ToolBuilderCreateResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_list() -> ToolBuilderListResponse: + """List all custom tools. + + Returns: + List of all custom tools with their details. + """ + try: + await require_auth() + storage = get_tool_storage() + tools = await storage.list_all() + + return ToolBuilderListResponse( + success=True, + data=[_to_custom_tool_data(t) for t in tools], + ) + + except Exception as e: + logger.exception("Failed to list custom tools") + return ToolBuilderListResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_get(name: str) -> ToolBuilderGetResponse: + """Get details of a custom tool. + + Args: + name: Name of the tool. + + Returns: + Tool details including code. + """ + try: + await require_auth() + storage = get_tool_storage() + tool_def = await storage.get(name) + + if tool_def is None: + return ToolBuilderGetResponse( + success=False, error=f"Tool '{name}' not found" + ) + + return ToolBuilderGetResponse(success=True, data=_to_custom_tool_data(tool_def)) + + except Exception as e: + logger.exception("Failed to get custom tool") + return ToolBuilderGetResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_delete(name: str) -> ToolBuilderDeleteResponse: + """Delete a custom tool. + + Args: + name: Name of the tool to delete. + + Returns: + Confirmation of deletion. + """ + try: + await require_auth() + storage = get_tool_storage() + + if not await storage.exists(name): + return ToolBuilderDeleteResponse( + success=False, error=f"Tool '{name}' not found" + ) + + await storage.delete(name) + + logger.info("Custom tool deleted name=%s", name) + return ToolBuilderDeleteResponse( + success=True, + data=ToolDeleteData(message=f"Tool '{name}' deleted", name=name), + ) + + except Exception as e: + logger.exception("Failed to delete custom tool") + return ToolBuilderDeleteResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_update( + name: str, + description: str | None = None, + code: str | None = None, + parameters: dict[str, Any] | None = None, +) -> ToolBuilderUpdateResponse: + """Update a custom tool. + + Args: + name: Name of the tool to update. + description: New description (optional). + code: New code (optional). + parameters: New parameters schema (optional). + + Returns: + Updated tool details. + """ + try: + await require_auth() + storage = get_tool_storage() + + if not await storage.exists(name): + return ToolBuilderUpdateResponse( + success=False, error=f"Tool '{name}' not found" + ) + + # Validate new code if provided + if code is not None: + is_valid, error = validate_tool_code(code) + if not is_valid: + return ToolBuilderUpdateResponse( + success=False, error=f"Invalid code: {error}" + ) + + # Build update kwargs + updates: dict[str, Any] = {} + if description is not None: + updates["description"] = description + if code is not None: + updates["code"] = code + if parameters is not None: + updates["parameters"] = parameters + + if not updates: + return ToolBuilderUpdateResponse(success=False, error="No updates provided") + + tool_def = await storage.update(name, **updates) + if tool_def is None: + return ToolBuilderUpdateResponse( + success=False, error=f"Tool '{name}' not found" + ) + + logger.info("Custom tool updated name=%s", name) + return ToolBuilderUpdateResponse( + success=True, data=_to_custom_tool_data(tool_def) + ) + + except Exception as e: + logger.exception("Failed to update custom tool") + return ToolBuilderUpdateResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_test( + name: str, + params: dict[str, Any] | None = None, +) -> ToolBuilderTestResponse: + """Test a custom tool with given parameters. + + Args: + name: Name of the tool to test. + params: Parameters to pass to the tool. + + Returns: + Execution result from the tool. + """ + try: + await require_auth() + storage = get_tool_storage() + tool_def = await storage.get(name) + + if tool_def is None: + return ToolBuilderTestResponse( + success=False, error=f"Tool '{name}' not found" + ) + + # Compile and execute + compiled = compile_code(tool_def.code) + result = await execute_sandboxed( + compiled_code=compiled, + function_name="execute", + params=params or {}, + ) + + return ToolBuilderTestResponse( + success=True, + data=ToolTestData(tool_name=name, result=result), + ) + + except CompilationError as e: + return ToolBuilderTestResponse(success=False, error=f"Compilation error: {e}") + except TimeoutError as e: + return ToolBuilderTestResponse(success=False, error=f"Timeout: {e}") + except ExecutionError as e: + return ToolBuilderTestResponse(success=False, error=f"Execution error: {e}") + except Exception as e: + logger.exception("Failed to test custom tool") + return ToolBuilderTestResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_enable(name: str) -> ToolBuilderEnableResponse: + """Enable a custom tool for MCP/REST access. + + When enabled, the tool is registered with MCP and becomes + available as a first-class tool. + + Args: + name: Name of the tool to enable. + + Returns: + Updated tool details. + """ + try: + await require_auth() + storage = get_tool_storage() + tool_def = await storage.get(name) + + if tool_def is None: + return ToolBuilderEnableResponse( + success=False, error=f"Tool '{name}' not found" + ) + + if tool_def.enabled: + return ToolBuilderEnableResponse( + success=False, error=f"Tool '{name}' is already enabled" + ) + + # Register with MCP + manager = get_custom_tool_manager() + if manager.is_initialized(): + if not await manager.register_tool(tool_def): + return ToolBuilderEnableResponse( + success=False, + error=f"Failed to register tool '{name}' with MCP", + ) + + tool_def = await storage.update(name, enabled=True) + if tool_def is None: + return ToolBuilderEnableResponse( + success=False, error=f"Tool '{name}' not found" + ) + + logger.info("Custom tool enabled name=%s", name) + return ToolBuilderEnableResponse( + success=True, data=_to_custom_tool_data(tool_def) + ) + + except Exception as e: + logger.exception("Failed to enable custom tool") + return ToolBuilderEnableResponse(success=False, error=str(e)) + + +@tool() +async def tool_builder_disable(name: str) -> ToolBuilderDisableResponse: + """Disable a custom tool from MCP/REST access. + + When disabled, the tool is unregistered from MCP. + Note: Due to FastMCP limitations, the tool may still appear + in the tool list until server restart. + + Args: + name: Name of the tool to disable. + + Returns: + Updated tool details. + """ + try: + await require_auth() + storage = get_tool_storage() + tool_def = await storage.get(name) + + if tool_def is None: + return ToolBuilderDisableResponse( + success=False, error=f"Tool '{name}' not found" + ) + + if not tool_def.enabled: + return ToolBuilderDisableResponse( + success=False, error=f"Tool '{name}' is already disabled" + ) + + # Unregister from MCP + manager = get_custom_tool_manager() + if manager.is_initialized(): + await manager.unregister_tool(name) + + tool_def = await storage.update(name, enabled=False) + if tool_def is None: + return ToolBuilderDisableResponse( + success=False, error=f"Tool '{name}' not found" + ) + + logger.info("Custom tool disabled name=%s", name) + return ToolBuilderDisableResponse( + success=True, data=_to_custom_tool_data(tool_def) + ) + + except Exception as e: + logger.exception("Failed to disable custom tool") + return ToolBuilderDisableResponse(success=False, error=str(e)) diff --git a/src/tools/calendar/SKILL.md b/src/tools/calendar/SKILL.md new file mode 100644 index 0000000..6663719 --- /dev/null +++ b/src/tools/calendar/SKILL.md @@ -0,0 +1,93 @@ +--- +name: calendar-tools +description: Manages calendar bookings and video conferencing. Use when the user needs to check availability, create bookings on Cal.com, or schedule and manage Zoom meetings. +--- + +# Calendar Tools + +Tools for booking management (Cal.com) and video conferencing (Zoom). + +## Cal.com + +### List bookings + +```python +result = await calcom_list_bookings(status="upcoming") +``` + +### Create booking + +```python +result = await calcom_create_booking( + event_type_id=12345, + start="2025-03-15T10:00:00Z", + name="John Doe", + email="john@example.com", + timezone="America/New_York" +) +``` + +### Check availability + +```python +result = await calcom_get_availability( + event_type_id=12345, + date_from="2025-03-15", + date_to="2025-03-20" +) +``` + +**Env:** `CALCOM_API_KEY`, `CALCOM_BASE_URL` (optional, defaults to `https://api.cal.com/v2`) + +## Zoom + +### Create meeting + +```python +result = await zoom_create_meeting( + topic="Weekly Standup", + start_time="2025-03-15T10:00:00Z", + duration=30, + timezone="America/New_York" +) +``` + +### List meetings + +```python +result = await zoom_list_meetings(meeting_type="scheduled") +``` + +### Get meeting details + +```python +result = await zoom_get_meeting(meeting_id="123456789") +``` + +**Env:** `ZOOM_ACCOUNT_ID`, `ZOOM_CLIENT_ID`, `ZOOM_CLIENT_SECRET` + +## Response Format + +All tools return: +```json +{ + "success": true, + "data": { ... } +} +``` + +On error: +```json +{ + "success": false, + "error": "Error description" +} +``` + +## When to Use + +- Checking calendar availability before scheduling +- Creating bookings or appointments via Cal.com +- Scheduling Zoom meetings for teams +- Listing upcoming meetings or bookings +- Building scheduling automation workflows diff --git a/src/tools/calendar/__init__.py b/src/tools/calendar/__init__.py new file mode 100644 index 0000000..e16fcea --- /dev/null +++ b/src/tools/calendar/__init__.py @@ -0,0 +1 @@ +# Calendar tools: booking management and video conferencing diff --git a/src/tools/calendar/calcom.py b/src/tools/calendar/calcom.py new file mode 100644 index 0000000..5f7be35 --- /dev/null +++ b/src/tools/calendar/calcom.py @@ -0,0 +1,478 @@ +"""Cal.com booking and scheduling management tools. + +Wraps the Cal.com Platform API v2 for managing bookings, availability, and +event types. Supports listing, creating, cancelling, and rescheduling bookings, +checking availability, and listing event types. + +Environment variables: + CALCOM_API_KEY: Bearer token for Cal.com API v2 authentication. + CALCOM_BASE_URL: Base URL override (default: https://api.cal.com/v2). +""" + +from __future__ import annotations + +import logging +import os + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.calendar.schemas import ( + CalcomAvailabilityData, + CalcomAvailabilityResponse, + CalcomAvailabilitySlot, + CalcomBooking, + CalcomCancelBookingData, + CalcomCancelBookingResponse, + CalcomCreateBookingData, + CalcomCreateBookingResponse, + CalcomEventType, + CalcomListBookingsData, + CalcomListBookingsResponse, + CalcomListEventTypesData, + CalcomListEventTypesResponse, + CalcomRescheduleBookingData, + CalcomRescheduleBookingResponse, +) + +logger = logging.getLogger("humcp.tools.calcom") + +CALCOM_DEFAULT_BASE_URL = "https://api.cal.com/v2" +CALCOM_API_VERSION = "2024-08-13" + + +def _get_calcom_config(api_key: str | None) -> tuple[str | None, str]: + """Resolve Cal.com API key and base URL. + + Args: + api_key: Resolved Cal.com API key. + + Returns: + Tuple of (api_key, base_url). + """ + base_url = os.getenv("CALCOM_BASE_URL", CALCOM_DEFAULT_BASE_URL) + return api_key, base_url + + +def _calcom_headers(api_key: str | None) -> dict[str, str]: + """Build Cal.com API request headers with versioning.""" + return { + "Authorization": f"Bearer {api_key}", + "cal-api-version": CALCOM_API_VERSION, + "Content-Type": "application/json", + } + + +@tool() +async def calcom_list_bookings( + status: str = "upcoming", +) -> CalcomListBookingsResponse: + """List bookings from Cal.com filtered by status. + + Retrieves bookings for the authenticated user with the given status filter. + + Args: + status: Booking status filter. Options: 'upcoming' (default), 'past', + 'cancelled', 'recurring'. + + Returns: + List of bookings with details including title, times, status, and attendee. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomListBookingsResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info("Cal.com list bookings start status=%s", status) + + url = f"{base_url}/bookings" + params = {"status": status} + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + url, headers=_calcom_headers(api_key), params=params + ) + response.raise_for_status() + + bookings_data = response.json().get("data", []) + + bookings = [ + CalcomBooking( + uid=booking["uid"], + title=booking.get("title", ""), + start_time=booking.get("start", ""), + end_time=booking.get("end"), + status=booking.get("status", ""), + attendee_name=booking.get("attendees", [{}])[0].get("name") + if booking.get("attendees") + else None, + attendee_email=booking.get("attendees", [{}])[0].get("email") + if booking.get("attendees") + else None, + location=booking.get("location"), + ) + for booking in bookings_data + ] + + logger.info("Cal.com list bookings complete count=%d", len(bookings)) + + return CalcomListBookingsResponse( + success=True, + data=CalcomListBookingsData( + bookings=bookings, + status_filter=status, + ), + ) + except Exception as e: + logger.exception("Cal.com list bookings failed") + return CalcomListBookingsResponse( + success=False, error=f"Cal.com list bookings failed: {str(e)}" + ) + + +@tool() +async def calcom_create_booking( + event_type_id: int, + start: str, + name: str, + email: str, + timezone: str = "America/New_York", + notes: str | None = None, + location: str | None = None, +) -> CalcomCreateBookingResponse: + """Create a new booking on Cal.com. + + Schedules a booking for a specific event type at the given start time. + The event type determines the duration and other settings. + + Args: + event_type_id: The Cal.com event type ID to book. + start: Start time in ISO 8601 format (e.g., '2025-03-15T10:00:00Z'). + name: Attendee's full name. + email: Attendee's email address. + timezone: Attendee's timezone in IANA format (default: 'America/New_York'). + notes: Optional notes or message from the attendee. + location: Optional meeting location or video link. + + Returns: + Booking confirmation with UID, start time, and attendee details. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomCreateBookingResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info( + "Cal.com create booking start event_type_id=%d start=%s", + event_type_id, + start, + ) + + url = f"{base_url}/bookings" + payload: dict = { + "start": start, + "eventTypeId": event_type_id, + "attendee": { + "name": name, + "email": email, + "timeZone": timezone, + }, + } + if notes: + payload["attendee"]["notes"] = notes + if location: + payload["location"] = location + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + url, headers=_calcom_headers(api_key), json=payload + ) + response.raise_for_status() + + booking_data = response.json().get("data", {}) + + logger.info( + "Cal.com create booking complete uid=%s", + booking_data.get("uid"), + ) + + return CalcomCreateBookingResponse( + success=True, + data=CalcomCreateBookingData( + uid=booking_data["uid"], + start_time=booking_data.get("start", start), + event_type_id=event_type_id, + attendee_name=name, + attendee_email=email, + ), + ) + except Exception as e: + logger.exception("Cal.com create booking failed") + return CalcomCreateBookingResponse( + success=False, error=f"Cal.com create booking failed: {str(e)}" + ) + + +@tool() +async def calcom_cancel_booking( + booking_uid: str, + reason: str | None = None, +) -> CalcomCancelBookingResponse: + """Cancel an existing Cal.com booking. + + Cancels a booking identified by its UID. An optional cancellation reason + can be provided which will be communicated to the attendee. + + Args: + booking_uid: The unique identifier (UID) of the booking to cancel. + reason: Optional reason for cancellation. + + Returns: + Cancellation confirmation. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomCancelBookingResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info("Cal.com cancel booking uid=%s", booking_uid) + + url = f"{base_url}/bookings/{booking_uid}/cancel" + payload: dict = {} + if reason: + payload["cancellationReason"] = reason + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + url, headers=_calcom_headers(api_key), json=payload + ) + response.raise_for_status() + + logger.info("Cal.com cancel booking complete uid=%s", booking_uid) + + return CalcomCancelBookingResponse( + success=True, + data=CalcomCancelBookingData( + uid=booking_uid, + message=f"Booking {booking_uid} cancelled successfully", + ), + ) + except Exception as e: + logger.exception("Cal.com cancel booking failed") + return CalcomCancelBookingResponse( + success=False, error=f"Cal.com cancel booking failed: {str(e)}" + ) + + +@tool() +async def calcom_reschedule_booking( + booking_uid: str, + new_start: str, + reason: str | None = None, +) -> CalcomRescheduleBookingResponse: + """Reschedule an existing Cal.com booking to a new time. + + Moves a booking to a new start time. The original booking is replaced + with a new one at the requested time. + + Args: + booking_uid: The unique identifier (UID) of the booking to reschedule. + new_start: New start time in ISO 8601 format (e.g., '2025-03-20T14:00:00Z'). + reason: Optional reason for rescheduling. + + Returns: + Reschedule confirmation with new booking details. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomRescheduleBookingResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info( + "Cal.com reschedule booking uid=%s new_start=%s", + booking_uid, + new_start, + ) + + url = f"{base_url}/bookings/{booking_uid}/reschedule" + payload: dict = {"start": new_start} + if reason: + payload["reschedulingReason"] = reason + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + url, headers=_calcom_headers(api_key), json=payload + ) + response.raise_for_status() + + result = response.json().get("data", {}) + new_uid = result.get("uid", booking_uid) + + logger.info( + "Cal.com reschedule booking complete old_uid=%s new_uid=%s", + booking_uid, + new_uid, + ) + + return CalcomRescheduleBookingResponse( + success=True, + data=CalcomRescheduleBookingData( + uid=new_uid, + start_time=result.get("start", new_start), + message=f"Booking rescheduled to {new_start}", + ), + ) + except Exception as e: + logger.exception("Cal.com reschedule booking failed") + return CalcomRescheduleBookingResponse( + success=False, error=f"Cal.com reschedule booking failed: {str(e)}" + ) + + +@tool() +async def calcom_get_availability( + event_type_id: int, + date_from: str, + date_to: str, +) -> CalcomAvailabilityResponse: + """Get available time slots for a Cal.com event type. + + Retrieves open slots within a date range for the specified event type. + Useful for finding available times before creating a booking. + + Args: + event_type_id: The Cal.com event type ID to check availability for. + date_from: Start date in YYYY-MM-DD format. + date_to: End date in YYYY-MM-DD format. + + Returns: + List of available time slots within the date range. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomAvailabilityResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info( + "Cal.com get availability start event_type_id=%d from=%s to=%s", + event_type_id, + date_from, + date_to, + ) + + url = f"{base_url}/slots/available" + params = { + "startTime": f"{date_from}T00:00:00Z", + "endTime": f"{date_to}T23:59:59Z", + "eventTypeId": str(event_type_id), + } + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + url, headers=_calcom_headers(api_key), params=params + ) + response.raise_for_status() + + slots_data = response.json().get("data", {}).get("slots", {}) + + slots = [ + CalcomAvailabilitySlot(time=slot["time"]) + for _date, time_slots in slots_data.items() + for slot in time_slots + ] + + logger.info("Cal.com get availability complete slots=%d", len(slots)) + + return CalcomAvailabilityResponse( + success=True, + data=CalcomAvailabilityData( + event_type_id=event_type_id, + date_from=date_from, + date_to=date_to, + slots=slots, + ), + ) + except Exception as e: + logger.exception("Cal.com get availability failed") + return CalcomAvailabilityResponse( + success=False, + error=f"Cal.com get availability failed: {str(e)}", + ) + + +@tool() +async def calcom_list_event_types() -> CalcomListEventTypesResponse: + """List all event types for the authenticated Cal.com user. + + Returns event types with their ID, title, slug, description, and duration. + Event type IDs are needed for creating bookings and checking availability. + + Returns: + List of event types with metadata. + """ + try: + api_key_val = await resolve_credential("CALCOM_API_KEY") + if not api_key_val: + return CalcomListEventTypesResponse( + success=False, error="Cal.com API not configured. Set CALCOM_API_KEY." + ) + + api_key, base_url = _get_calcom_config(api_key_val) + + logger.info("Cal.com list event types start") + + url = f"{base_url}/event-types" + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(url, headers=_calcom_headers(api_key)) + response.raise_for_status() + + event_types_data = response.json().get("data", []) + + event_types = [ + CalcomEventType( + id=et["id"], + title=et.get("title", ""), + slug=et.get("slug"), + description=et.get("description"), + length=et.get("length"), + hidden=et.get("hidden"), + ) + for et in event_types_data + ] + + logger.info("Cal.com list event types complete count=%d", len(event_types)) + + return CalcomListEventTypesResponse( + success=True, + data=CalcomListEventTypesData( + event_types=event_types, + count=len(event_types), + ), + ) + except Exception as e: + logger.exception("Cal.com list event types failed") + return CalcomListEventTypesResponse( + success=False, error=f"Cal.com list event types failed: {str(e)}" + ) diff --git a/src/tools/calendar/schemas.py b/src/tools/calendar/schemas.py new file mode 100644 index 0000000..7f856cf --- /dev/null +++ b/src/tools/calendar/schemas.py @@ -0,0 +1,258 @@ +"""Pydantic output schemas for calendar tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Cal.com Schemas +# ============================================================================= + + +class CalcomBooking(BaseModel): + """A single booking from Cal.com.""" + + uid: str = Field(..., description="Unique booking identifier") + title: str = Field(..., description="Booking title") + start_time: str = Field(..., description="Booking start time in ISO 8601 format") + end_time: str | None = Field( + None, description="Booking end time in ISO 8601 format" + ) + status: str = Field( + ..., description="Booking status (accepted, pending, cancelled)" + ) + attendee_name: str | None = Field(None, description="Primary attendee name") + attendee_email: str | None = Field(None, description="Primary attendee email") + location: str | None = Field(None, description="Meeting location or video link") + + +class CalcomListBookingsData(BaseModel): + """Output data for calcom_list_bookings tool.""" + + bookings: list[CalcomBooking] = Field( + default_factory=list, description="List of bookings" + ) + status_filter: str | None = Field(None, description="Status filter applied") + + +class CalcomCreateBookingData(BaseModel): + """Output data for calcom_create_booking tool.""" + + uid: str = Field(..., description="Unique booking identifier") + start_time: str = Field(..., description="Booking start time") + event_type_id: int = Field(..., description="Event type ID") + attendee_name: str = Field(..., description="Attendee name") + attendee_email: str = Field(..., description="Attendee email") + + +class CalcomCancelBookingData(BaseModel): + """Output data for calcom_cancel_booking tool.""" + + uid: str = Field(..., description="Booking UID that was cancelled") + message: str = Field(..., description="Cancellation confirmation message") + + +class CalcomRescheduleBookingData(BaseModel): + """Output data for calcom_reschedule_booking tool.""" + + uid: str = Field(..., description="New booking UID after reschedule") + start_time: str = Field(..., description="New start time") + message: str = Field(..., description="Reschedule confirmation message") + + +class CalcomAvailabilitySlot(BaseModel): + """A single availability slot from Cal.com.""" + + time: str = Field(..., description="Available time slot in ISO 8601 format") + + +class CalcomAvailabilityData(BaseModel): + """Output data for calcom_get_availability tool.""" + + event_type_id: int = Field(..., description="Event type ID") + date_from: str = Field(..., description="Start date of availability range") + date_to: str = Field(..., description="End date of availability range") + slots: list[CalcomAvailabilitySlot] = Field( + default_factory=list, description="List of available time slots" + ) + + +class CalcomEventType(BaseModel): + """A Cal.com event type.""" + + id: int = Field(..., description="Event type ID") + title: str = Field(..., description="Event type title") + slug: str | None = Field(None, description="URL slug for the event type") + description: str | None = Field(None, description="Event type description") + length: int | None = Field(None, description="Duration in minutes") + hidden: bool | None = Field(None, description="Whether the event type is hidden") + + +class CalcomListEventTypesData(BaseModel): + """Output data for calcom_list_event_types tool.""" + + event_types: list[CalcomEventType] = Field( + default_factory=list, description="List of event types" + ) + count: int = Field(..., description="Total number of event types") + + +# ============================================================================= +# Zoom Schemas +# ============================================================================= + + +class ZoomMeeting(BaseModel): + """A single Zoom meeting.""" + + meeting_id: str = Field(..., description="Zoom meeting ID") + topic: str = Field(..., description="Meeting topic/title") + start_time: str | None = Field( + None, description="Meeting start time in ISO 8601 format" + ) + duration: int | None = Field(None, description="Meeting duration in minutes") + timezone: str | None = Field(None, description="Meeting timezone (IANA format)") + join_url: str | None = Field(None, description="URL to join the meeting") + status: str | None = Field(None, description="Meeting status (waiting, started)") + type: int | None = Field( + None, + description="Meeting type (1=instant, 2=scheduled, 3=recurring no fixed time, 8=recurring fixed time)", + ) + + +class ZoomCreateMeetingData(BaseModel): + """Output data for zoom_create_meeting tool.""" + + meeting_id: str = Field(..., description="Created meeting ID") + topic: str = Field(..., description="Meeting topic") + start_time: str = Field(..., description="Meeting start time") + duration: int = Field(..., description="Meeting duration in minutes") + timezone: str = Field(..., description="Meeting timezone") + join_url: str = Field(..., description="URL to join the meeting") + password: str | None = Field(None, description="Meeting password if set") + + +class ZoomListMeetingsData(BaseModel): + """Output data for zoom_list_meetings tool.""" + + meetings: list[ZoomMeeting] = Field( + default_factory=list, description="List of meetings" + ) + total_records: int = Field(0, description="Total number of meetings") + + +class ZoomGetMeetingData(BaseModel): + """Output data for zoom_get_meeting tool.""" + + meeting: ZoomMeeting = Field(..., description="Meeting details") + + +class ZoomDeleteMeetingData(BaseModel): + """Output data for zoom_delete_meeting tool.""" + + meeting_id: str = Field(..., description="ID of the deleted meeting") + message: str = Field(..., description="Deletion confirmation message") + + +class ZoomUpdateMeetingData(BaseModel): + """Output data for zoom_update_meeting tool.""" + + meeting_id: str = Field(..., description="ID of the updated meeting") + message: str = Field(..., description="Update confirmation message") + + +class ZoomMeetingParticipant(BaseModel): + """A participant in a Zoom meeting.""" + + name: str = Field(..., description="Participant name") + email: str | None = Field(None, description="Participant email") + join_time: str | None = Field(None, description="Time the participant joined") + leave_time: str | None = Field(None, description="Time the participant left") + duration: int | None = Field(None, description="Duration in the meeting in seconds") + + +class ZoomListParticipantsData(BaseModel): + """Output data for zoom_list_meeting_participants tool.""" + + meeting_id: str = Field(..., description="Meeting ID") + participants: list[ZoomMeetingParticipant] = Field( + default_factory=list, description="List of participants" + ) + total_records: int = Field(0, description="Total number of participants") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class CalcomListBookingsResponse(ToolResponse[CalcomListBookingsData]): + """Response schema for calcom_list_bookings tool.""" + + pass + + +class CalcomCreateBookingResponse(ToolResponse[CalcomCreateBookingData]): + """Response schema for calcom_create_booking tool.""" + + pass + + +class CalcomCancelBookingResponse(ToolResponse[CalcomCancelBookingData]): + """Response schema for calcom_cancel_booking tool.""" + + pass + + +class CalcomRescheduleBookingResponse(ToolResponse[CalcomRescheduleBookingData]): + """Response schema for calcom_reschedule_booking tool.""" + + pass + + +class CalcomAvailabilityResponse(ToolResponse[CalcomAvailabilityData]): + """Response schema for calcom_get_availability tool.""" + + pass + + +class CalcomListEventTypesResponse(ToolResponse[CalcomListEventTypesData]): + """Response schema for calcom_list_event_types tool.""" + + pass + + +class ZoomCreateMeetingResponse(ToolResponse[ZoomCreateMeetingData]): + """Response schema for zoom_create_meeting tool.""" + + pass + + +class ZoomListMeetingsResponse(ToolResponse[ZoomListMeetingsData]): + """Response schema for zoom_list_meetings tool.""" + + pass + + +class ZoomGetMeetingResponse(ToolResponse[ZoomGetMeetingData]): + """Response schema for zoom_get_meeting tool.""" + + pass + + +class ZoomDeleteMeetingResponse(ToolResponse[ZoomDeleteMeetingData]): + """Response schema for zoom_delete_meeting tool.""" + + pass + + +class ZoomUpdateMeetingResponse(ToolResponse[ZoomUpdateMeetingData]): + """Response schema for zoom_update_meeting tool.""" + + pass + + +class ZoomListParticipantsResponse(ToolResponse[ZoomListParticipantsData]): + """Response schema for zoom_list_meeting_participants tool.""" + + pass diff --git a/src/tools/calendar/zoom.py b/src/tools/calendar/zoom.py new file mode 100644 index 0000000..f9ede71 --- /dev/null +++ b/src/tools/calendar/zoom.py @@ -0,0 +1,474 @@ +"""Zoom meeting management tools. + +Wraps the Zoom REST API v2 for creating, listing, updating, deleting meetings, +and retrieving meeting participants. Uses Server-to-Server OAuth for +authentication. + +Environment variables: + ZOOM_ACCOUNT_ID: Zoom Server-to-Server OAuth account ID. + ZOOM_CLIENT_ID: Zoom Server-to-Server OAuth client ID. + ZOOM_CLIENT_SECRET: Zoom Server-to-Server OAuth client secret. +""" + +from __future__ import annotations + +import logging +from base64 import b64encode + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.calendar.schemas import ( + ZoomCreateMeetingData, + ZoomCreateMeetingResponse, + ZoomDeleteMeetingData, + ZoomDeleteMeetingResponse, + ZoomGetMeetingData, + ZoomGetMeetingResponse, + ZoomListMeetingsData, + ZoomListMeetingsResponse, + ZoomListParticipantsData, + ZoomListParticipantsResponse, + ZoomMeeting, + ZoomMeetingParticipant, + ZoomUpdateMeetingData, + ZoomUpdateMeetingResponse, +) + +logger = logging.getLogger("humcp.tools.zoom") + +ZOOM_TOKEN_URL = "https://zoom.us/oauth/token" +ZOOM_API_BASE = "https://api.zoom.us/v2" + +_NOT_CONFIGURED_MSG = "Zoom API not configured. Set ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET." + + +async def _get_zoom_token() -> str | None: + """Obtain a Zoom access token via Server-to-Server OAuth. + + Returns None if the required credentials are not available. + """ + account_id = await resolve_credential("ZOOM_ACCOUNT_ID") + client_id = await resolve_credential("ZOOM_CLIENT_ID") + client_secret = await resolve_credential("ZOOM_CLIENT_SECRET") + + if not account_id or not client_id or not client_secret: + return None + + auth_string = b64encode(f"{client_id}:{client_secret}".encode()).decode() + + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + ZOOM_TOKEN_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {auth_string}", + }, + data={ + "grant_type": "account_credentials", + "account_id": account_id, + }, + ) + response.raise_for_status() + return response.json()["access_token"] + + +async def _zoom_request( + endpoint: str, + token: str, + method: str = "GET", + json_data: dict | None = None, + params: dict | None = None, +) -> dict: + """Make an authenticated request to the Zoom API. + + Returns the JSON response body, or ``{"success": True}`` for 204 responses. + """ + url = f"{ZOOM_API_BASE}/{endpoint}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.request( + method=method, + url=url, + headers=headers, + json=json_data, + params=params, + ) + if response.status_code == 204: + return {"success": True} + response.raise_for_status() + return response.json() + + +@tool() +async def zoom_create_meeting( + topic: str, + start_time: str, + duration: int, + timezone: str = "UTC", + agenda: str | None = None, + password: str | None = None, +) -> ZoomCreateMeetingResponse: + """Schedule a new Zoom meeting. + + Creates a scheduled meeting (type 2) with the specified topic, time, + and duration. Returns the meeting details including the join URL. + + Args: + topic: The topic or title of the meeting. + start_time: Start time in ISO 8601 format (e.g., '2025-03-15T10:00:00Z'). + duration: Duration of the meeting in minutes (1-1440). + timezone: Timezone for the meeting in IANA format (default: 'UTC'). + Examples: 'America/New_York', 'Europe/London', 'Asia/Tokyo'. + agenda: Optional meeting description/agenda text. + password: Optional meeting password (max 10 characters). + + Returns: + Meeting details including ID, join URL, and password. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomCreateMeetingResponse(success=False, error=_NOT_CONFIGURED_MSG) + + logger.info( + "Zoom create meeting start topic=%s start_time=%s duration=%d", + topic, + start_time, + duration, + ) + + meeting_data: dict = { + "topic": topic, + "type": 2, + "start_time": start_time, + "duration": duration, + "timezone": timezone, + "settings": { + "host_video": True, + "participant_video": True, + "join_before_host": False, + "mute_upon_entry": False, + "audio": "voip", + "auto_recording": "none", + }, + } + if agenda: + meeting_data["agenda"] = agenda + if password: + meeting_data["password"] = password + + result = await _zoom_request( + "users/me/meetings", token, method="POST", json_data=meeting_data + ) + + logger.info( + "Zoom create meeting complete meeting_id=%s", + result.get("id"), + ) + + return ZoomCreateMeetingResponse( + success=True, + data=ZoomCreateMeetingData( + meeting_id=str(result["id"]), + topic=result["topic"], + start_time=result["start_time"], + duration=result["duration"], + timezone=result.get("timezone", timezone), + join_url=result["join_url"], + password=result.get("password"), + ), + ) + except Exception as e: + logger.exception("Zoom create meeting failed") + return ZoomCreateMeetingResponse( + success=False, error=f"Zoom create meeting failed: {str(e)}" + ) + + +@tool() +async def zoom_list_meetings( + meeting_type: str = "scheduled", + page_size: int = 30, +) -> ZoomListMeetingsResponse: + """List Zoom meetings for the authenticated user. + + Returns meetings matching the specified type filter with pagination. + + Args: + meeting_type: Type of meetings to return. Options: + 'scheduled' (default), 'live', 'upcoming', 'upcoming_meetings', + 'previous_meetings'. + page_size: Number of records per page (1-300, default: 30). + + Returns: + List of meetings with details. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomListMeetingsResponse(success=False, error=_NOT_CONFIGURED_MSG) + + logger.info("Zoom list meetings start type=%s", meeting_type) + + result = await _zoom_request( + "users/me/meetings", + token, + params={"type": meeting_type, "page_size": min(page_size, 300)}, + ) + + meetings_data = result.get("meetings", []) + meetings = [ + ZoomMeeting( + meeting_id=str(meeting["id"]), + topic=meeting.get("topic", ""), + start_time=meeting.get("start_time"), + duration=meeting.get("duration"), + timezone=meeting.get("timezone"), + join_url=meeting.get("join_url"), + status=meeting.get("status"), + type=meeting.get("type"), + ) + for meeting in meetings_data + ] + + total_records = result.get("total_records", len(meetings)) + + logger.info("Zoom list meetings complete count=%d", len(meetings)) + + return ZoomListMeetingsResponse( + success=True, + data=ZoomListMeetingsData( + meetings=meetings, + total_records=total_records, + ), + ) + except Exception as e: + logger.exception("Zoom list meetings failed") + return ZoomListMeetingsResponse( + success=False, error=f"Zoom list meetings failed: {str(e)}" + ) + + +@tool() +async def zoom_get_meeting( + meeting_id: str, +) -> ZoomGetMeetingResponse: + """Get details of a specific Zoom meeting. + + Retrieves full meeting details including topic, time, duration, timezone, + join URL, and current status. + + Args: + meeting_id: The Zoom meeting ID to retrieve. + + Returns: + Meeting details. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomGetMeetingResponse(success=False, error=_NOT_CONFIGURED_MSG) + + logger.info("Zoom get meeting start meeting_id=%s", meeting_id) + + result = await _zoom_request(f"meetings/{meeting_id}", token) + + meeting = ZoomMeeting( + meeting_id=str(result.get("id", meeting_id)), + topic=result.get("topic", ""), + start_time=result.get("start_time"), + duration=result.get("duration"), + timezone=result.get("timezone"), + join_url=result.get("join_url"), + status=result.get("status"), + type=result.get("type"), + ) + + logger.info("Zoom get meeting complete topic=%s", meeting.topic) + + return ZoomGetMeetingResponse( + success=True, + data=ZoomGetMeetingData(meeting=meeting), + ) + except Exception as e: + logger.exception("Zoom get meeting failed") + return ZoomGetMeetingResponse( + success=False, error=f"Zoom get meeting failed: {str(e)}" + ) + + +@tool() +async def zoom_delete_meeting( + meeting_id: str, +) -> ZoomDeleteMeetingResponse: + """Delete a Zoom meeting. + + Permanently deletes the specified meeting. This action cannot be undone. + + Args: + meeting_id: The Zoom meeting ID to delete. + + Returns: + Deletion confirmation. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomDeleteMeetingResponse(success=False, error=_NOT_CONFIGURED_MSG) + + logger.info("Zoom delete meeting start meeting_id=%s", meeting_id) + + await _zoom_request(f"meetings/{meeting_id}", token, method="DELETE") + + logger.info("Zoom delete meeting complete meeting_id=%s", meeting_id) + + return ZoomDeleteMeetingResponse( + success=True, + data=ZoomDeleteMeetingData( + meeting_id=meeting_id, + message=f"Meeting {meeting_id} deleted successfully", + ), + ) + except Exception as e: + logger.exception("Zoom delete meeting failed") + return ZoomDeleteMeetingResponse( + success=False, error=f"Zoom delete meeting failed: {str(e)}" + ) + + +@tool() +async def zoom_update_meeting( + meeting_id: str, + topic: str | None = None, + start_time: str | None = None, + duration: int | None = None, + timezone: str | None = None, + agenda: str | None = None, +) -> ZoomUpdateMeetingResponse: + """Update an existing Zoom meeting. + + Modifies meeting properties. Only the provided fields are updated; + omitted fields remain unchanged. + + Args: + meeting_id: The Zoom meeting ID to update. + topic: New meeting topic/title. + start_time: New start time in ISO 8601 format. + duration: New duration in minutes. + timezone: New timezone in IANA format. + agenda: New meeting agenda/description. + + Returns: + Update confirmation. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomUpdateMeetingResponse(success=False, error=_NOT_CONFIGURED_MSG) + + logger.info("Zoom update meeting start meeting_id=%s", meeting_id) + + update_data: dict = {} + if topic is not None: + update_data["topic"] = topic + if start_time is not None: + update_data["start_time"] = start_time + if duration is not None: + update_data["duration"] = duration + if timezone is not None: + update_data["timezone"] = timezone + if agenda is not None: + update_data["agenda"] = agenda + + if not update_data: + return ZoomUpdateMeetingResponse( + success=False, + error="No fields provided to update. Specify at least one of: topic, start_time, duration, timezone, agenda.", + ) + + await _zoom_request( + f"meetings/{meeting_id}", token, method="PATCH", json_data=update_data + ) + + logger.info("Zoom update meeting complete meeting_id=%s", meeting_id) + + return ZoomUpdateMeetingResponse( + success=True, + data=ZoomUpdateMeetingData( + meeting_id=meeting_id, + message=f"Meeting {meeting_id} updated successfully", + ), + ) + except Exception as e: + logger.exception("Zoom update meeting failed") + return ZoomUpdateMeetingResponse( + success=False, error=f"Zoom update meeting failed: {str(e)}" + ) + + +@tool() +async def zoom_list_meeting_participants( + meeting_id: str, +) -> ZoomListParticipantsResponse: + """List participants from a past Zoom meeting. + + Retrieves participant details from a completed meeting including join/leave + times and duration. Only works for past meetings. + + Args: + meeting_id: The Zoom meeting ID (must be a past meeting). + + Returns: + List of participants with join/leave times and duration. + """ + try: + token = await _get_zoom_token() + if not token: + return ZoomListParticipantsResponse( + success=False, error=_NOT_CONFIGURED_MSG + ) + + logger.info("Zoom list participants start meeting_id=%s", meeting_id) + + result = await _zoom_request(f"past_meetings/{meeting_id}/participants", token) + + participants_data = result.get("participants", []) + participants = [ + ZoomMeetingParticipant( + name=p.get("name", "Unknown"), + email=p.get("user_email"), + join_time=p.get("join_time"), + leave_time=p.get("leave_time"), + duration=p.get("duration"), + ) + for p in participants_data + ] + + total_records = result.get("total_records", len(participants)) + + logger.info( + "Zoom list participants complete meeting_id=%s count=%d", + meeting_id, + len(participants), + ) + + return ZoomListParticipantsResponse( + success=True, + data=ZoomListParticipantsData( + meeting_id=meeting_id, + participants=participants, + total_records=total_records, + ), + ) + except Exception as e: + logger.exception("Zoom list participants failed") + return ZoomListParticipantsResponse( + success=False, + error=f"Zoom list meeting participants failed: {str(e)}", + ) diff --git a/src/tools/cloud/SKILL.md b/src/tools/cloud/SKILL.md new file mode 100644 index 0000000..4148359 --- /dev/null +++ b/src/tools/cloud/SKILL.md @@ -0,0 +1,182 @@ +--- +name: cloud-infrastructure +description: Manage cloud infrastructure, serverless functions, containers, and development sandboxes. Use when the user needs to invoke AWS Lambda functions, send emails via SES, manage Docker containers, create Daytona workspaces, run code in E2B sandboxes, or interact with Apache Airflow DAGs. +--- + +# Cloud Infrastructure Tools + +Tools for interacting with cloud services, container runtimes, and development sandboxes. + +## Available Tools + +| Tool | Service | API Key Required | Best For | +|------|---------|-----------------|----------| +| `aws_lambda_invoke` | AWS Lambda | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Invoking serverless functions | +| `aws_lambda_list_functions` | AWS Lambda | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Listing available Lambda functions | +| `aws_ses_send_email` | AWS SES | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Sending transactional emails | +| `docker_list_containers` | Docker | None (local socket) | Listing Docker containers | +| `docker_run_container` | Docker | None (local socket) | Running a new container | +| `docker_stop_container` | Docker | None (local socket) | Stopping a running container | +| `daytona_create_workspace` | Daytona | `DAYTONA_API_KEY` | Creating cloud dev workspaces | +| `daytona_list_workspaces` | Daytona | `DAYTONA_API_KEY` | Listing Daytona workspaces | +| `daytona_run_command` | Daytona | `DAYTONA_API_KEY` | Executing commands in a workspace | +| `e2b_run_code` | E2B | `E2B_API_KEY` | Running code in a cloud sandbox | +| `e2b_run_command` | E2B | `E2B_API_KEY` | Running shell commands in a sandbox | +| `airflow_list_dags` | Airflow | `AIRFLOW_USERNAME`, `AIRFLOW_PASSWORD` | Listing Airflow DAGs | +| `airflow_trigger_dag` | Airflow | `AIRFLOW_USERNAME`, `AIRFLOW_PASSWORD` | Triggering a DAG run | +| `airflow_get_dag_run` | Airflow | `AIRFLOW_USERNAME`, `AIRFLOW_PASSWORD` | Checking DAG run status | + +## Environment Variables + +### AWS (Lambda & SES) +- `AWS_ACCESS_KEY_ID` - AWS access key +- `AWS_SECRET_ACCESS_KEY` - AWS secret key +- `AWS_DEFAULT_REGION` - AWS region (default: `us-east-1`) +- `AWS_SES_FROM_EMAIL` - Default sender email for SES + +### Docker +- No API key needed; uses the local Docker socket + +### Daytona +- `DAYTONA_API_KEY` - Daytona API key +- `DAYTONA_SERVER_URL` - Daytona server URL (default: `https://api.daytona.io`) + +### E2B +- `E2B_API_KEY` - E2B API key + +### Airflow +- `AIRFLOW_BASE_URL` - Airflow REST API base URL (default: `http://localhost:8080`) +- `AIRFLOW_USERNAME` - Airflow username +- `AIRFLOW_PASSWORD` - Airflow password + +## Quick Examples + +### AWS Lambda + +```python +# List functions +result = await aws_lambda_list_functions(region="us-west-2") + +# Invoke a function +result = await aws_lambda_invoke( + function_name="my-function", + payload='{"key": "value"}', + region="us-east-1" +) +``` + +### AWS SES + +```python +result = await aws_ses_send_email( + to="recipient@example.com", + subject="Hello from SES", + body="This is a test email.", + from_addr="sender@example.com" +) +``` + +### Docker + +```python +# List running containers +result = await docker_list_containers() + +# List all containers (including stopped) +result = await docker_list_containers(all=True) + +# Run a container +result = await docker_run_container( + image="python:3.13-slim", + command="python -c 'print(\"hello\")'", + environment={"MY_VAR": "value"} +) + +# Stop a container +result = await docker_stop_container(container_id="abc123") +``` + +### Daytona + +```python +# Create a workspace +result = await daytona_create_workspace( + repo_url="https://github.com/user/repo" +) + +# List workspaces +result = await daytona_list_workspaces() + +# Run a command +result = await daytona_run_command( + workspace_id="ws-123", + command="ls -la" +) +``` + +### E2B + +```python +# Run Python code +result = await e2b_run_code( + code="print('Hello from E2B!')", + language="python", + timeout=60 +) + +# Run a shell command +result = await e2b_run_command( + command="pip install requests && python -c 'import requests; print(requests.__version__)'", + timeout=120 +) +``` + +### Airflow + +```python +# List DAGs +result = await airflow_list_dags() + +# Trigger a DAG +result = await airflow_trigger_dag( + dag_id="my_etl_pipeline", + conf={"param1": "value1"} +) + +# Check DAG run status +result = await airflow_get_dag_run( + dag_id="my_etl_pipeline", + run_id="manual__2025-01-01T00:00:00+00:00" +) +``` + +## Response Format + +All cloud tools return a consistent response structure: + +```json +{ + "success": true, + "data": { + "...": "tool-specific fields" + } +} +``` + +On failure: + +```json +{ + "success": false, + "error": "Description of what went wrong" +} +``` + +## When to Use + +- **Run serverless functions**: Use `aws_lambda_invoke` +- **Send emails**: Use `aws_ses_send_email` +- **Manage containers**: Use `docker_list_containers`, `docker_run_container`, `docker_stop_container` +- **Cloud dev environments**: Use `daytona_create_workspace`, `daytona_run_command` +- **Execute code safely**: Use `e2b_run_code`, `e2b_run_command` +- **Orchestrate data pipelines**: Use `airflow_trigger_dag`, `airflow_get_dag_run` diff --git a/src/tools/cloud/__init__.py b/src/tools/cloud/__init__.py new file mode 100644 index 0000000..05b176b --- /dev/null +++ b/src/tools/cloud/__init__.py @@ -0,0 +1 @@ +# Cloud infrastructure and compute tools diff --git a/src/tools/cloud/airflow.py b/src/tools/cloud/airflow.py new file mode 100644 index 0000000..367e3f7 --- /dev/null +++ b/src/tools/cloud/airflow.py @@ -0,0 +1,413 @@ +"""Apache Airflow tools for managing DAGs, DAG runs, and task instances via the stable REST API (v1). + +Environment variables: + AIRFLOW_BASE_URL: Base URL of the Airflow webserver (default: http://localhost:8080). + AIRFLOW_USERNAME: Username for basic authentication. + AIRFLOW_PASSWORD: Password for basic authentication. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + AirflowDagInfo, + AirflowDagRunData, + AirflowDagRunResponse, + AirflowListDagsData, + AirflowListDagsResponse, + AirflowListTaskInstancesData, + AirflowListTaskInstancesResponse, + AirflowPauseDagData, + AirflowPauseDagResponse, + AirflowTaskInstanceInfo, + AirflowTriggerDagData, + AirflowTriggerDagResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Airflow tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.airflow") + + +def _get_airflow_config( + username: str | None, password: str | None +) -> tuple[str, httpx.BasicAuth | None]: + """Resolve Airflow base URL and authentication. + + Args: + username: Airflow username. + password: Airflow password. + + Returns: + Tuple of (base_url, auth). + """ + base_url = os.getenv("AIRFLOW_BASE_URL", "http://localhost:8080") + auth = httpx.BasicAuth(username, password) if username and password else None + return base_url.rstrip("/"), auth + + +@tool() +async def airflow_list_dags( + only_active: bool = True, +) -> AirflowListDagsResponse: + """List all DAGs in the Airflow instance. + + Retrieves DAG metadata including schedule, pause state, owners, and tags + from the Airflow stable REST API (``/api/v1/dags``). + + Args: + only_active: If True, only return active (unpaused) DAGs. Defaults to True. + + Returns: + List of DAG information objects. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Listing Airflow DAGs only_active=%s", only_active) + params: dict[str, Any] = {} + if only_active: + params["only_active"] = "true" + + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.get( + f"{base_url}/api/v1/dags", + headers={"Content-Type": "application/json"}, + params=params, + ) + response.raise_for_status() + result = response.json() + + dags = [ + AirflowDagInfo( + dag_id=dag["dag_id"], + description=dag.get("description"), + is_paused=dag.get("is_paused"), + is_active=dag.get("is_active"), + file_token=dag.get("file_token"), + owners=dag.get("owners"), + schedule_interval=dag.get("schedule_interval", {}).get("value") + if isinstance(dag.get("schedule_interval"), dict) + else dag.get("schedule_interval"), + tags=[t["name"] for t in dag.get("tags", []) if isinstance(t, dict)] + if dag.get("tags") + else None, + ) + for dag in result.get("dags", []) + ] + + data = AirflowListDagsData( + dags=dags, + count=len(dags), + ) + + logger.info("Listed %d Airflow DAGs", len(dags)) + return AirflowListDagsResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowListDagsResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list Airflow DAGs") + return AirflowListDagsResponse( + success=False, error=f"Failed to list DAGs: {str(e)}" + ) + + +@tool() +async def airflow_trigger_dag( + dag_id: str, + conf: dict[str, Any] | None = None, + logical_date: str | None = None, + note: str | None = None, +) -> AirflowTriggerDagResponse: + """Trigger a new Airflow DAG run. + + Creates a DAG run via ``POST /api/v1/dags/{dag_id}/dagRuns``. An optional + JSON configuration dict can be passed to parameterise the run. + + Args: + dag_id: The identifier of the DAG to trigger. + conf: Optional configuration dict to pass to the DAG run. + logical_date: Optional logical date in ISO 8601 format for the DAG run. + note: Optional note to attach to the DAG run. + + Returns: + DAG run identifier, state, and execution date. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Triggering Airflow DAG dag_id=%s", dag_id) + payload: dict[str, Any] = {} + if conf is not None: + payload["conf"] = conf + if logical_date is not None: + payload["logical_date"] = logical_date + if note is not None: + payload["note"] = note + + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.post( + f"{base_url}/api/v1/dags/{dag_id}/dagRuns", + headers={"Content-Type": "application/json"}, + json=payload, + ) + response.raise_for_status() + result = response.json() + + data = AirflowTriggerDagData( + dag_id=dag_id, + dag_run_id=result.get("dag_run_id", "unknown"), + state=result.get("state"), + execution_date=result.get("execution_date") or result.get("logical_date"), + ) + + logger.info("DAG triggered dag_id=%s run_id=%s", dag_id, data.dag_run_id) + return AirflowTriggerDagResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowTriggerDagResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to trigger Airflow DAG") + return AirflowTriggerDagResponse( + success=False, error=f"Failed to trigger DAG: {str(e)}" + ) + + +@tool() +async def airflow_get_dag_run( + dag_id: str, + run_id: str, +) -> AirflowDagRunResponse: + """Get the status of a specific Airflow DAG run. + + Retrieves run details including state, start/end dates from + ``GET /api/v1/dags/{dag_id}/dagRuns/{run_id}``. + + Args: + dag_id: The DAG identifier. + run_id: The DAG run identifier (dag_run_id). + + Returns: + DAG run state and timing details. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Getting Airflow DAG run dag_id=%s run_id=%s", dag_id, run_id) + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.get( + f"{base_url}/api/v1/dags/{dag_id}/dagRuns/{run_id}", + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + result = response.json() + + data = AirflowDagRunData( + dag_id=dag_id, + dag_run_id=result.get("dag_run_id", run_id), + state=result.get("state", "unknown"), + execution_date=result.get("execution_date") or result.get("logical_date"), + start_date=result.get("start_date"), + end_date=result.get("end_date"), + ) + + logger.info( + "DAG run status dag_id=%s run_id=%s state=%s", + dag_id, + run_id, + data.state, + ) + return AirflowDagRunResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowDagRunResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to get Airflow DAG run") + return AirflowDagRunResponse( + success=False, error=f"Failed to get DAG run: {str(e)}" + ) + + +@tool() +async def airflow_list_task_instances( + dag_id: str, + run_id: str, +) -> AirflowListTaskInstancesResponse: + """List task instances for a specific Airflow DAG run. + + Returns the individual task states within a DAG run, useful for + debugging failures or monitoring progress. + + Args: + dag_id: The DAG identifier. + run_id: The DAG run identifier (dag_run_id). + + Returns: + List of task instance details including state, duration, and operator. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Listing task instances dag_id=%s run_id=%s", dag_id, run_id) + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.get( + f"{base_url}/api/v1/dags/{dag_id}/dagRuns/{run_id}/taskInstances", + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + result = response.json() + + task_instances = [ + AirflowTaskInstanceInfo( + task_id=ti["task_id"], + state=ti.get("state"), + start_date=ti.get("start_date"), + end_date=ti.get("end_date"), + duration=ti.get("duration"), + try_number=ti.get("try_number"), + operator=ti.get("operator"), + ) + for ti in result.get("task_instances", []) + ] + + data = AirflowListTaskInstancesData( + dag_id=dag_id, + dag_run_id=run_id, + task_instances=task_instances, + count=len(task_instances), + ) + + logger.info( + "Listed %d task instances for dag_id=%s run_id=%s", + len(task_instances), + dag_id, + run_id, + ) + return AirflowListTaskInstancesResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowListTaskInstancesResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list task instances") + return AirflowListTaskInstancesResponse( + success=False, error=f"Failed to list task instances: {str(e)}" + ) + + +@tool() +async def airflow_pause_dag( + dag_id: str, +) -> AirflowPauseDagResponse: + """Pause an Airflow DAG to prevent new runs from being scheduled. + + Sets ``is_paused=true`` on the DAG via ``PATCH /api/v1/dags/{dag_id}``. + + Args: + dag_id: The DAG identifier to pause. + + Returns: + Confirmation of pause state. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Pausing Airflow DAG dag_id=%s", dag_id) + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.patch( + f"{base_url}/api/v1/dags/{dag_id}", + headers={"Content-Type": "application/json"}, + json={"is_paused": True}, + ) + response.raise_for_status() + + data = AirflowPauseDagData(dag_id=dag_id, is_paused=True) + logger.info("DAG paused dag_id=%s", dag_id) + return AirflowPauseDagResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowPauseDagResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to pause Airflow DAG") + return AirflowPauseDagResponse( + success=False, error=f"Failed to pause DAG: {str(e)}" + ) + + +@tool() +async def airflow_unpause_dag( + dag_id: str, +) -> AirflowPauseDagResponse: + """Unpause an Airflow DAG to allow new runs to be scheduled. + + Sets ``is_paused=false`` on the DAG via ``PATCH /api/v1/dags/{dag_id}``. + + Args: + dag_id: The DAG identifier to unpause. + + Returns: + Confirmation of pause state. + """ + try: + username = await resolve_credential("AIRFLOW_USERNAME") + password = await resolve_credential("AIRFLOW_PASSWORD") + base_url, auth = _get_airflow_config(username, password) + + logger.info("Unpausing Airflow DAG dag_id=%s", dag_id) + async with httpx.AsyncClient(timeout=30, auth=auth) as client: + response = await client.patch( + f"{base_url}/api/v1/dags/{dag_id}", + headers={"Content-Type": "application/json"}, + json={"is_paused": False}, + ) + response.raise_for_status() + + data = AirflowPauseDagData(dag_id=dag_id, is_paused=False) + logger.info("DAG unpaused dag_id=%s", dag_id) + return AirflowPauseDagResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Airflow API returned an error") + return AirflowPauseDagResponse( + success=False, + error=f"Airflow API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to unpause Airflow DAG") + return AirflowPauseDagResponse( + success=False, error=f"Failed to unpause DAG: {str(e)}" + ) diff --git a/src/tools/cloud/aws_lambda.py b/src/tools/cloud/aws_lambda.py new file mode 100644 index 0000000..4f010be --- /dev/null +++ b/src/tools/cloud/aws_lambda.py @@ -0,0 +1,365 @@ +"""AWS Lambda tools for managing Lambda functions via boto3. + +Supports invoking functions, listing functions, retrieving function details, +updating function code, listing event source mappings, and listing aliases. + +Environment variables: + AWS_ACCESS_KEY_ID: AWS access key. + AWS_SECRET_ACCESS_KEY: AWS secret key. + AWS_DEFAULT_REGION: Default region (fallback: us-east-1). +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + LambdaEventSourceMapping, + LambdaFunctionSummary, + LambdaGetFunctionData, + LambdaGetFunctionResponse, + LambdaInvokeData, + LambdaInvokeResponse, + LambdaListEventSourceMappingsData, + LambdaListEventSourceMappingsResponse, + LambdaListFunctionsData, + LambdaListFunctionsResponse, + LambdaUpdateCodeData, + LambdaUpdateCodeResponse, +) + +try: + import boto3 +except ImportError as err: + raise ImportError( + "boto3 is required for AWS Lambda tools. Install with: pip install boto3" + ) from err + +logger = logging.getLogger("humcp.tools.aws_lambda") + + +def _get_lambda_client(aws_key: str, aws_secret: str, region: str | None = None): + """Create a boto3 Lambda client with explicit credentials.""" + resolved_region = region or os.getenv("AWS_DEFAULT_REGION", "us-east-1") + return boto3.client( + "lambda", + region_name=resolved_region, + aws_access_key_id=aws_key, + aws_secret_access_key=aws_secret, + ) + + +@tool() +async def aws_lambda_invoke( + function_name: str, + payload: str = "{}", + invocation_type: str = "RequestResponse", + region: str | None = None, +) -> LambdaInvokeResponse: + """Invoke an AWS Lambda function with an optional JSON payload. + + Calls the function synchronously (RequestResponse) or asynchronously (Event). + The response includes the function output, status code, and any error information. + + Args: + function_name: The name, ARN, or partial ARN of the Lambda function to invoke. + payload: JSON string payload to send to the function. Defaults to "{}". + invocation_type: How to invoke the function. One of 'RequestResponse' (synchronous, + default), 'Event' (asynchronous), or 'DryRun' (validate without executing). + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + Invocation result including status code, response payload, and error info. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return LambdaInvokeResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info( + "Invoking Lambda function=%s type=%s", function_name, invocation_type + ) + client = _get_lambda_client(aws_key, aws_secret, region) + response = client.invoke( + FunctionName=function_name, + Payload=payload, + InvocationType=invocation_type, + ) + + response_payload = response["Payload"].read().decode("utf-8") + executed_version = response.get("ExecutedVersion") + function_error = response.get("FunctionError") + + data = LambdaInvokeData( + function_name=function_name, + status_code=response["StatusCode"], + payload=response_payload, + executed_version=executed_version, + function_error=function_error, + ) + + logger.info( + "Lambda invocation complete function=%s status=%d", + function_name, + response["StatusCode"], + ) + return LambdaInvokeResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to invoke Lambda function") + return LambdaInvokeResponse( + success=False, error=f"Lambda invocation failed: {str(e)}" + ) + + +@tool() +async def aws_lambda_list_functions( + region: str | None = None, +) -> LambdaListFunctionsResponse: + """List all AWS Lambda functions in the configured account and region. + + Returns function metadata including name, ARN, runtime, handler, memory, + timeout, code size, and description. + + Args: + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + List of Lambda function summaries. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return LambdaListFunctionsResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info("Listing Lambda functions") + client = _get_lambda_client(aws_key, aws_secret, region) + response = client.list_functions() + + functions = [ + LambdaFunctionSummary( + function_name=func["FunctionName"], + function_arn=func.get("FunctionArn"), + runtime=func.get("Runtime"), + handler=func.get("Handler"), + last_modified=func.get("LastModified"), + memory_size=func.get("MemorySize"), + timeout=func.get("Timeout"), + code_size=func.get("CodeSize"), + description=func.get("Description"), + ) + for func in response.get("Functions", []) + ] + + data = LambdaListFunctionsData( + functions=functions, + count=len(functions), + ) + + logger.info("Listed %d Lambda functions", len(functions)) + return LambdaListFunctionsResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to list Lambda functions") + return LambdaListFunctionsResponse( + success=False, error=f"Failed to list Lambda functions: {str(e)}" + ) + + +@tool() +async def aws_lambda_get_function( + function_name: str, + region: str | None = None, +) -> LambdaGetFunctionResponse: + """Get detailed information about an AWS Lambda function. + + Retrieves the function configuration, code location, tags, and + concurrency settings. Includes environment variables, layers, state, + and last update status. + + Args: + function_name: The name, ARN, or partial ARN of the Lambda function. + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + Detailed function configuration including runtime, role, layers, + environment variables, and state. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return LambdaGetFunctionResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info("Getting Lambda function details function=%s", function_name) + client = _get_lambda_client(aws_key, aws_secret, region) + response = client.get_function(FunctionName=function_name) + + config = response.get("Configuration", {}) + env_vars = config.get("Environment", {}).get("Variables") + layers_list = config.get("Layers", []) + layer_arns = [layer["Arn"] for layer in layers_list] if layers_list else None + + data = LambdaGetFunctionData( + function_name=config.get("FunctionName", function_name), + function_arn=config.get("FunctionArn", ""), + runtime=config.get("Runtime"), + handler=config.get("Handler"), + role=config.get("Role"), + code_size=config.get("CodeSize"), + description=config.get("Description"), + timeout=config.get("Timeout"), + memory_size=config.get("MemorySize"), + last_modified=config.get("LastModified"), + state=config.get("State"), + last_update_status=config.get("LastUpdateStatus"), + environment_variables=env_vars, + layers=layer_arns, + ) + + logger.info("Got Lambda function details function=%s", function_name) + return LambdaGetFunctionResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Lambda function details") + return LambdaGetFunctionResponse( + success=False, error=f"Failed to get function details: {str(e)}" + ) + + +@tool() +async def aws_lambda_update_function_code( + function_name: str, + s3_bucket: str, + s3_key: str, + region: str | None = None, +) -> LambdaUpdateCodeResponse: + """Update the deployment package of an AWS Lambda function from S3. + + Deploys new code from a ZIP archive stored in Amazon S3 to the + specified Lambda function. + + Args: + function_name: The name or ARN of the Lambda function to update. + s3_bucket: S3 bucket containing the deployment package (.zip). + s3_key: S3 object key of the deployment package (.zip). + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + Updated function metadata including new code size and update status. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return LambdaUpdateCodeResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info( + "Updating Lambda function code function=%s s3=%s/%s", + function_name, + s3_bucket, + s3_key, + ) + client = _get_lambda_client(aws_key, aws_secret, region) + response = client.update_function_code( + FunctionName=function_name, + S3Bucket=s3_bucket, + S3Key=s3_key, + ) + + data = LambdaUpdateCodeData( + function_name=response.get("FunctionName", function_name), + function_arn=response.get("FunctionArn", ""), + runtime=response.get("Runtime"), + code_size=response.get("CodeSize"), + last_modified=response.get("LastModified"), + last_update_status=response.get("LastUpdateStatus"), + ) + + logger.info( + "Lambda function code updated function=%s status=%s", + function_name, + data.last_update_status, + ) + return LambdaUpdateCodeResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Lambda function code") + return LambdaUpdateCodeResponse( + success=False, error=f"Failed to update function code: {str(e)}" + ) + + +@tool() +async def aws_lambda_list_event_source_mappings( + function_name: str | None = None, + region: str | None = None, +) -> LambdaListEventSourceMappingsResponse: + """List event source mappings for AWS Lambda functions. + + Returns mappings that connect event sources (SQS, Kinesis, DynamoDB Streams, + etc.) to Lambda functions. Can be filtered to a specific function. + + Args: + function_name: Optional function name or ARN to filter mappings. + If omitted, lists all event source mappings in the account. + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + List of event source mappings with UUID, ARNs, state, and batch size. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return LambdaListEventSourceMappingsResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info("Listing event source mappings function=%s", function_name) + client = _get_lambda_client(aws_key, aws_secret, region) + + kwargs = {} + if function_name: + kwargs["FunctionName"] = function_name + + response = client.list_event_source_mappings(**kwargs) + + mappings = [ + LambdaEventSourceMapping( + uuid=m["UUID"], + function_arn=m.get("FunctionArn"), + event_source_arn=m.get("EventSourceArn"), + state=m.get("State"), + batch_size=m.get("BatchSize"), + last_modified=str(m["LastModified"]) if m.get("LastModified") else None, + ) + for m in response.get("EventSourceMappings", []) + ] + + data = LambdaListEventSourceMappingsData( + event_source_mappings=mappings, + count=len(mappings), + ) + + logger.info("Listed %d event source mappings", len(mappings)) + return LambdaListEventSourceMappingsResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to list event source mappings") + return LambdaListEventSourceMappingsResponse( + success=False, error=f"Failed to list event source mappings: {str(e)}" + ) diff --git a/src/tools/cloud/aws_ses.py b/src/tools/cloud/aws_ses.py new file mode 100644 index 0000000..b648546 --- /dev/null +++ b/src/tools/cloud/aws_ses.py @@ -0,0 +1,305 @@ +"""AWS SES tools for sending emails, managing identities, and viewing statistics. + +Wraps Amazon Simple Email Service (SES) via boto3. Supports sending emails, +listing verified identities, verifying new email addresses, and retrieving +sending statistics. + +Environment variables: + AWS_ACCESS_KEY_ID: AWS access key. + AWS_SECRET_ACCESS_KEY: AWS secret key. + AWS_DEFAULT_REGION: Default region (fallback: us-east-1). + AWS_SES_FROM_EMAIL: Default sender email address. +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + SesIdentityInfo, + SesListIdentitiesData, + SesListIdentitiesResponse, + SesSendEmailData, + SesSendEmailResponse, + SesSendStatisticsData, + SesSendStatisticsEntry, + SesSendStatisticsResponse, + SesVerifyEmailData, + SesVerifyEmailResponse, +) + +try: + import boto3 +except ImportError as err: + raise ImportError( + "boto3 is required for AWS SES tools. Install with: pip install boto3" + ) from err + +logger = logging.getLogger("humcp.tools.aws_ses") + + +def _get_ses_client(aws_key: str, aws_secret: str, region: str | None = None): + """Create a boto3 SES client with explicit credentials.""" + resolved_region = region or os.getenv("AWS_DEFAULT_REGION", "us-east-1") + return boto3.client( + "ses", + region_name=resolved_region, + aws_access_key_id=aws_key, + aws_secret_access_key=aws_secret, + ) + + +@tool() +async def aws_ses_send_email( + to: str, + subject: str, + body: str, + from_addr: str | None = None, + html_body: str | None = None, + cc: str | None = None, + bcc: str | None = None, + region: str | None = None, +) -> SesSendEmailResponse: + """Send an email using AWS Simple Email Service (SES). + + Sends a formatted email to a single recipient with plain text and optional + HTML body. The sender address must be verified in SES. + + Args: + to: Recipient email address. + subject: Email subject line. + body: Plain text email body. + from_addr: Sender email address. Falls back to AWS_SES_FROM_EMAIL env var. + html_body: Optional HTML version of the email body. + cc: Optional CC recipient email address. + bcc: Optional BCC recipient email address. + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + Result with SES message ID on success. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return SesSendEmailResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + resolved_from = from_addr or await resolve_credential("AWS_SES_FROM_EMAIL") + if not resolved_from: + return SesSendEmailResponse( + success=False, + error="Sender email not configured. Provide from_addr or set AWS_SES_FROM_EMAIL.", + ) + + if not subject: + return SesSendEmailResponse( + success=False, error="Email subject cannot be empty." + ) + + if not body: + return SesSendEmailResponse( + success=False, error="Email body cannot be empty." + ) + + logger.info("Sending email via SES to=%s subject=%s", to, subject) + client = _get_ses_client(aws_key, aws_secret, region) + + destination: dict = {"ToAddresses": [to]} + if cc: + destination["CcAddresses"] = [cc] + if bcc: + destination["BccAddresses"] = [bcc] + + message_body: dict = { + "Text": {"Charset": "UTF-8", "Data": body}, + } + if html_body: + message_body["Html"] = {"Charset": "UTF-8", "Data": html_body} + + response = client.send_email( + Source=resolved_from, + Destination=destination, + Message={ + "Body": message_body, + "Subject": {"Charset": "UTF-8", "Data": subject}, + }, + ) + + message_id = response["MessageId"] + data = SesSendEmailData( + message_id=message_id, + to=to, + subject=subject, + ) + + logger.info("Email sent successfully message_id=%s", message_id) + return SesSendEmailResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to send email via SES") + return SesSendEmailResponse(success=False, error=f"SES email failed: {str(e)}") + + +@tool() +async def aws_ses_list_identities( + identity_type: str = "EmailAddress", + region: str | None = None, +) -> SesListIdentitiesResponse: + """List verified identities (email addresses or domains) in AWS SES. + + Returns all identities of the specified type that have been submitted + for verification, regardless of verification status. + + Args: + identity_type: Type of identity to list. One of 'EmailAddress' (default) + or 'Domain'. + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + List of identity strings (email addresses or domains). + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return SesListIdentitiesResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + valid_types = {"EmailAddress", "Domain"} + if identity_type not in valid_types: + return SesListIdentitiesResponse( + success=False, + error=f"Invalid identity_type '{identity_type}'. Must be one of: {', '.join(sorted(valid_types))}", + ) + + logger.info("Listing SES identities type=%s", identity_type) + client = _get_ses_client(aws_key, aws_secret, region) + response = client.list_identities(IdentityType=identity_type, MaxItems=100) + + identities = [ + SesIdentityInfo(identity=ident, identity_type=identity_type) + for ident in response.get("Identities", []) + ] + + data = SesListIdentitiesData( + identities=identities, + count=len(identities), + ) + + logger.info("Listed %d SES identities", len(identities)) + return SesListIdentitiesResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to list SES identities") + return SesListIdentitiesResponse( + success=False, error=f"Failed to list SES identities: {str(e)}" + ) + + +@tool() +async def aws_ses_get_send_statistics( + region: str | None = None, +) -> SesSendStatisticsResponse: + """Get sending statistics for AWS SES. + + Returns a list of data points representing the last two weeks of sending + activity, including delivery attempts, bounces, complaints, and rejects. + + Args: + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + List of send statistics data points. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return SesSendStatisticsResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + logger.info("Getting SES send statistics") + client = _get_ses_client(aws_key, aws_secret, region) + response = client.get_send_statistics() + + statistics = [ + SesSendStatisticsEntry( + timestamp=str(dp.get("Timestamp", "")) if dp.get("Timestamp") else None, + delivery_attempts=dp.get("DeliveryAttempts", 0), + bounces=dp.get("Bounces", 0), + complaints=dp.get("Complaints", 0), + rejects=dp.get("Rejects", 0), + ) + for dp in response.get("SendDataPoints", []) + ] + + data = SesSendStatisticsData( + statistics=statistics, + count=len(statistics), + ) + + logger.info("Got %d SES statistics data points", len(statistics)) + return SesSendStatisticsResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get SES send statistics") + return SesSendStatisticsResponse( + success=False, error=f"Failed to get send statistics: {str(e)}" + ) + + +@tool() +async def aws_ses_verify_email( + email: str, + region: str | None = None, +) -> SesVerifyEmailResponse: + """Send a verification email to an address for use with AWS SES. + + Amazon SES sends a verification email to the specified address. The + recipient must click the link in the email to complete verification + before the address can be used as a sender. + + Args: + email: The email address to verify. + region: AWS region. Falls back to AWS_DEFAULT_REGION env var or us-east-1. + + Returns: + Confirmation that the verification email was sent. + """ + try: + aws_key = await resolve_credential("AWS_ACCESS_KEY_ID") + aws_secret = await resolve_credential("AWS_SECRET_ACCESS_KEY") + if not aws_key or not aws_secret: + return SesVerifyEmailResponse( + success=False, + error="AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.", + ) + + if not email: + return SesVerifyEmailResponse( + success=False, error="Email address cannot be empty." + ) + + logger.info("Sending SES verification email to=%s", email) + client = _get_ses_client(aws_key, aws_secret, region) + client.verify_email_identity(EmailAddress=email) + + data = SesVerifyEmailData( + email=email, + message=f"Verification email sent to {email}. Check inbox and click the verification link.", + ) + + logger.info("SES verification email sent to=%s", email) + return SesVerifyEmailResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to send SES verification email") + return SesVerifyEmailResponse( + success=False, error=f"Failed to verify email: {str(e)}" + ) diff --git a/src/tools/cloud/daytona.py b/src/tools/cloud/daytona.py new file mode 100644 index 0000000..2351723 --- /dev/null +++ b/src/tools/cloud/daytona.py @@ -0,0 +1,404 @@ +"""Daytona tools for managing cloud development workspaces. + +Supports creating, listing, starting, stopping, deleting workspaces, and +running commands inside them via the Daytona REST API. + +Environment variables: + DAYTONA_API_KEY: Bearer token for Daytona API authentication. + DAYTONA_SERVER_URL: Daytona server URL (default: https://api.daytona.io). +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + DaytonaCreateWorkspaceData, + DaytonaCreateWorkspaceResponse, + DaytonaDeleteWorkspaceData, + DaytonaDeleteWorkspaceResponse, + DaytonaListWorkspacesData, + DaytonaListWorkspacesResponse, + DaytonaRunCommandData, + DaytonaRunCommandResponse, + DaytonaStartStopWorkspaceData, + DaytonaStartStopWorkspaceResponse, + DaytonaWorkspaceInfo, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Daytona tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.daytona") + + +def _get_daytona_config(api_key: str | None) -> tuple[str, dict[str, str]]: + """Resolve Daytona server URL and authorization headers. + + Args: + api_key: Resolved Daytona API key. + + Returns: + Tuple of (base_url, headers). + """ + server_url = os.getenv("DAYTONA_SERVER_URL", "https://api.daytona.io") + headers: dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return server_url.rstrip("/"), headers + + +@tool() +async def daytona_create_workspace( + repo_url: str, + name: str | None = None, +) -> DaytonaCreateWorkspaceResponse: + """Create a new Daytona workspace from a Git repository URL. + + Provisions a cloud development environment with the repository code + pre-cloned and ready to use. + + Args: + repo_url: The Git repository URL to initialize the workspace from + (e.g. 'https://github.com/owner/repo'). + name: Optional workspace name. Auto-generated if omitted. + + Returns: + Created workspace ID and repository URL. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaCreateWorkspaceResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info("Creating Daytona workspace repo_url=%s name=%s", repo_url, name) + payload: dict = {"repositories": [{"url": repo_url}]} + if name: + payload["name"] = name + + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + f"{base_url}/workspaces", + headers=headers, + json=payload, + ) + response.raise_for_status() + result = response.json() + + workspace_id = result.get("id", result.get("workspaceId", "unknown")) + data = DaytonaCreateWorkspaceData( + workspace_id=str(workspace_id), + repo_url=repo_url, + ) + + logger.info("Workspace created id=%s", workspace_id) + return DaytonaCreateWorkspaceResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaCreateWorkspaceResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to create Daytona workspace") + return DaytonaCreateWorkspaceResponse( + success=False, error=f"Failed to create workspace: {str(e)}" + ) + + +@tool() +async def daytona_list_workspaces() -> DaytonaListWorkspacesResponse: + """List all Daytona workspaces. + + Returns workspace metadata including ID, name, state, and repository URL. + + Returns: + List of workspace information objects. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaListWorkspacesResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info("Listing Daytona workspaces") + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + f"{base_url}/workspaces", + headers=headers, + ) + response.raise_for_status() + result = response.json() + + workspaces_raw = result if isinstance(result, list) else result.get("items", []) + workspaces = [ + DaytonaWorkspaceInfo( + id=str(ws.get("id", ws.get("workspaceId", "unknown"))), + name=ws.get("name"), + state=ws.get("state"), + repo_url=ws.get("repository", {}).get("url") + if isinstance(ws.get("repository"), dict) + else None, + ) + for ws in workspaces_raw + ] + + data = DaytonaListWorkspacesData( + workspaces=workspaces, + count=len(workspaces), + ) + + logger.info("Listed %d Daytona workspaces", len(workspaces)) + return DaytonaListWorkspacesResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaListWorkspacesResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list Daytona workspaces") + return DaytonaListWorkspacesResponse( + success=False, error=f"Failed to list workspaces: {str(e)}" + ) + + +@tool() +async def daytona_run_command( + workspace_id: str, + command: str, +) -> DaytonaRunCommandResponse: + """Run a shell command inside a Daytona workspace. + + Executes the command in the workspace's default project container and + returns the output and exit code. + + Args: + workspace_id: The ID of the workspace to run the command in. + command: The shell command to execute. + + Returns: + Command output and exit code. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaRunCommandResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info( + "Running command in Daytona workspace=%s command=%s", + workspace_id, + command[:100], + ) + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{base_url}/workspaces/{workspace_id}/exec", + headers=headers, + json={"command": command}, + ) + response.raise_for_status() + result = response.json() + + output = result.get("output", result.get("result", "")) + exit_code = result.get("exitCode", result.get("exit_code")) + + data = DaytonaRunCommandData( + workspace_id=workspace_id, + output=str(output), + exit_code=exit_code, + ) + + logger.info( + "Command completed workspace=%s exit_code=%s", workspace_id, exit_code + ) + return DaytonaRunCommandResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaRunCommandResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to run command in Daytona workspace") + return DaytonaRunCommandResponse( + success=False, error=f"Failed to run command: {str(e)}" + ) + + +@tool() +async def daytona_start_workspace( + workspace_id: str, +) -> DaytonaStartStopWorkspaceResponse: + """Start a stopped Daytona workspace. + + Resumes a previously stopped workspace, restoring its state and files. + + Args: + workspace_id: The ID of the workspace to start. + + Returns: + Confirmation that the workspace is starting. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaStartStopWorkspaceResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info("Starting Daytona workspace id=%s", workspace_id) + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + f"{base_url}/workspaces/{workspace_id}/start", + headers=headers, + ) + response.raise_for_status() + + data = DaytonaStartStopWorkspaceData( + workspace_id=workspace_id, + message=f"Workspace {workspace_id} is starting", + ) + + logger.info("Workspace start initiated id=%s", workspace_id) + return DaytonaStartStopWorkspaceResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaStartStopWorkspaceResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to start Daytona workspace") + return DaytonaStartStopWorkspaceResponse( + success=False, error=f"Failed to start workspace: {str(e)}" + ) + + +@tool() +async def daytona_stop_workspace( + workspace_id: str, +) -> DaytonaStartStopWorkspaceResponse: + """Stop a running Daytona workspace. + + Pauses the workspace to save resources. Files and state are preserved + and the workspace can be restarted later. + + Args: + workspace_id: The ID of the workspace to stop. + + Returns: + Confirmation that the workspace is stopping. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaStartStopWorkspaceResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info("Stopping Daytona workspace id=%s", workspace_id) + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + f"{base_url}/workspaces/{workspace_id}/stop", + headers=headers, + ) + response.raise_for_status() + + data = DaytonaStartStopWorkspaceData( + workspace_id=workspace_id, + message=f"Workspace {workspace_id} is stopping", + ) + + logger.info("Workspace stop initiated id=%s", workspace_id) + return DaytonaStartStopWorkspaceResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaStartStopWorkspaceResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to stop Daytona workspace") + return DaytonaStartStopWorkspaceResponse( + success=False, error=f"Failed to stop workspace: {str(e)}" + ) + + +@tool() +async def daytona_delete_workspace( + workspace_id: str, +) -> DaytonaDeleteWorkspaceResponse: + """Delete a Daytona workspace permanently. + + Removes the workspace and all associated resources. This action + cannot be undone. + + Args: + workspace_id: The ID of the workspace to delete. + + Returns: + Confirmation that the workspace was deleted. + """ + try: + api_key = await resolve_credential("DAYTONA_API_KEY") + if not api_key: + return DaytonaDeleteWorkspaceResponse( + success=False, + error="Daytona API key not configured. Set DAYTONA_API_KEY.", + ) + + base_url, headers = _get_daytona_config(api_key) + + logger.info("Deleting Daytona workspace id=%s", workspace_id) + async with httpx.AsyncClient(timeout=60) as client: + response = await client.delete( + f"{base_url}/workspaces/{workspace_id}", + headers=headers, + ) + response.raise_for_status() + + data = DaytonaDeleteWorkspaceData( + workspace_id=workspace_id, + message=f"Workspace {workspace_id} deleted successfully", + ) + + logger.info("Workspace deleted id=%s", workspace_id) + return DaytonaDeleteWorkspaceResponse(success=True, data=data) + except httpx.HTTPStatusError as e: + logger.exception("Daytona API returned an error") + return DaytonaDeleteWorkspaceResponse( + success=False, + error=f"Daytona API error {e.response.status_code}: {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to delete Daytona workspace") + return DaytonaDeleteWorkspaceResponse( + success=False, error=f"Failed to delete workspace: {str(e)}" + ) diff --git a/src/tools/cloud/docker.py b/src/tools/cloud/docker.py new file mode 100644 index 0000000..7b213d6 --- /dev/null +++ b/src/tools/cloud/docker.py @@ -0,0 +1,340 @@ +"""Docker tools for managing containers and images via the local Docker daemon. + +Uses the ``docker`` Python SDK (docker-py) to communicate with the Docker +Engine API through the local socket. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + DockerContainerInfo, + DockerContainerLogsData, + DockerContainerLogsResponse, + DockerImageInfo, + DockerListContainersData, + DockerListContainersResponse, + DockerListImagesData, + DockerListImagesResponse, + DockerRemoveContainerData, + DockerRemoveContainerResponse, + DockerRunContainerData, + DockerRunContainerResponse, + DockerStopContainerData, + DockerStopContainerResponse, +) + +try: + from docker.errors import DockerException + + import docker +except ImportError as err: + raise ImportError( + "docker is required for Docker tools. Install with: pip install docker" + ) from err + +logger = logging.getLogger("humcp.tools.docker") + + +def _get_docker_client(): + """Create a Docker client connected to the local daemon.""" + return docker.from_env() + + +@tool() +async def docker_list_containers( + all: bool = False, +) -> DockerListContainersResponse: + """List Docker containers on the local Docker daemon. + + Returns container metadata including ID, name, image, status, creation + time, and port mappings. + + Args: + all: If True, show all containers including stopped ones. + Defaults to False (only running containers). + + Returns: + List of container information objects. + """ + try: + logger.info("Listing Docker containers all=%s", all) + client = _get_docker_client() + containers = client.containers.list(all=all) + + container_list = [ + DockerContainerInfo( + id=container.id, + name=container.name, + image=( + container.image.tags[0] + if container.image.tags + else container.image.id + ), + status=container.status, + created=container.attrs.get("Created"), + ports=container.ports, + ) + for container in containers + ] + + data = DockerListContainersData( + containers=container_list, + count=len(container_list), + ) + + logger.info("Listed %d Docker containers", len(container_list)) + return DockerListContainersResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to list Docker containers") + return DockerListContainersResponse( + success=False, error=f"Docker error: {str(e)}" + ) + except Exception as e: + logger.exception("Failed to list Docker containers") + return DockerListContainersResponse( + success=False, error=f"Failed to list containers: {str(e)}" + ) + + +@tool() +async def docker_run_container( + image: str, + command: str | None = None, + name: str | None = None, + environment: dict[str, str] | None = None, + ports: dict[str, int] | None = None, + detach: bool = True, +) -> DockerRunContainerResponse: + """Run a new Docker container from an image. + + Pulls the image if not available locally, then creates and starts a + container. The container runs in detached mode by default. + + Args: + image: Docker image name (e.g. 'python:3.13-slim', 'nginx:latest'). + command: Optional command to run inside the container. + name: Optional container name. Docker assigns a random name if omitted. + environment: Optional dict of environment variables to set. + ports: Optional port mapping dict (container_port -> host_port), + e.g. {"80": 8080} maps container port 80 to host port 8080. + detach: Run container in background. Defaults to True. + + Returns: + Container ID and image information on success. + """ + try: + logger.info("Running Docker container image=%s", image) + client = _get_docker_client() + + kwargs: dict[str, Any] = { + "image": image, + "detach": detach, + } + if command is not None: + kwargs["command"] = command + if name is not None: + kwargs["name"] = name + if environment is not None: + kwargs["environment"] = environment + if ports is not None: + kwargs["ports"] = {f"{cp}/tcp": int(hp) for cp, hp in ports.items()} + + container = client.containers.run(**kwargs) + + data = DockerRunContainerData( + container_id=container.id, + image=image, + ) + + logger.info("Container started id=%s image=%s", container.id, image) + return DockerRunContainerResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to run Docker container") + return DockerRunContainerResponse( + success=False, error=f"Docker error: {str(e)}" + ) + except Exception as e: + logger.exception("Failed to run Docker container") + return DockerRunContainerResponse( + success=False, error=f"Failed to run container: {str(e)}" + ) + + +@tool() +async def docker_stop_container( + container_id: str, + timeout: int = 10, +) -> DockerStopContainerResponse: + """Stop a running Docker container. + + Sends SIGTERM and waits for the container to stop. If the container + does not stop within the timeout, SIGKILL is sent. + + Args: + container_id: The ID or name of the container to stop. + timeout: Seconds to wait before sending SIGKILL. Defaults to 10. + + Returns: + Confirmation message on success. + """ + try: + logger.info("Stopping Docker container id=%s timeout=%d", container_id, timeout) + client = _get_docker_client() + container = client.containers.get(container_id) + container.stop(timeout=timeout) + + data = DockerStopContainerData( + container_id=container_id, + message=f"Container {container_id} stopped successfully", + ) + + logger.info("Container stopped id=%s", container_id) + return DockerStopContainerResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to stop Docker container") + return DockerStopContainerResponse( + success=False, error=f"Docker error: {str(e)}" + ) + except Exception as e: + logger.exception("Failed to stop Docker container") + return DockerStopContainerResponse( + success=False, error=f"Failed to stop container: {str(e)}" + ) + + +@tool() +async def docker_get_container_logs( + container_id: str, + tail: int = 100, + timestamps: bool = False, +) -> DockerContainerLogsResponse: + """Get logs from a Docker container. + + Retrieves stdout and stderr output from the container. Use ``tail`` + to limit the number of lines returned. + + Args: + container_id: The ID or name of the container. + tail: Number of lines to return from the end of the logs. Defaults to 100. + timestamps: Whether to include timestamps in each log line. Defaults to False. + + Returns: + Container log output as a string. + """ + try: + logger.info("Getting logs container=%s tail=%d", container_id, tail) + client = _get_docker_client() + container = client.containers.get(container_id) + logs = container.logs(tail=tail, timestamps=timestamps) + + log_text = ( + logs.decode("utf-8", errors="replace") + if isinstance(logs, bytes) + else str(logs) + ) + + data = DockerContainerLogsData( + container_id=container_id, + logs=log_text, + tail=tail, + ) + + logger.info("Got logs container=%s lines=%d", container_id, tail) + return DockerContainerLogsResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to get container logs") + return DockerContainerLogsResponse( + success=False, error=f"Docker error: {str(e)}" + ) + except Exception as e: + logger.exception("Failed to get container logs") + return DockerContainerLogsResponse( + success=False, error=f"Failed to get container logs: {str(e)}" + ) + + +@tool() +async def docker_remove_container( + container_id: str, + force: bool = False, +) -> DockerRemoveContainerResponse: + """Remove a Docker container. + + Removes a stopped container. Use ``force=True`` to remove a running + container (it will be killed first). + + Args: + container_id: The ID or name of the container to remove. + force: Force removal of a running container. Defaults to False. + + Returns: + Confirmation message on success. + """ + try: + logger.info("Removing Docker container id=%s force=%s", container_id, force) + client = _get_docker_client() + container = client.containers.get(container_id) + container.remove(force=force) + + data = DockerRemoveContainerData( + container_id=container_id, + message=f"Container {container_id} removed successfully", + ) + + logger.info("Container removed id=%s", container_id) + return DockerRemoveContainerResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to remove Docker container") + return DockerRemoveContainerResponse( + success=False, error=f"Docker error: {str(e)}" + ) + except Exception as e: + logger.exception("Failed to remove Docker container") + return DockerRemoveContainerResponse( + success=False, error=f"Failed to remove container: {str(e)}" + ) + + +@tool() +async def docker_list_images() -> DockerListImagesResponse: + """List Docker images available on the local Docker daemon. + + Returns image metadata including ID, tags, size, and creation date. + + Returns: + List of Docker image information objects. + """ + try: + logger.info("Listing Docker images") + client = _get_docker_client() + images = client.images.list() + + image_list = [ + DockerImageInfo( + id=img.id, + tags=img.tags if img.tags else [], + size=img.attrs.get("Size"), + created=img.attrs.get("Created"), + ) + for img in images + ] + + data = DockerListImagesData( + images=image_list, + count=len(image_list), + ) + + logger.info("Listed %d Docker images", len(image_list)) + return DockerListImagesResponse(success=True, data=data) + except DockerException as e: + logger.exception("Failed to list Docker images") + return DockerListImagesResponse(success=False, error=f"Docker error: {str(e)}") + except Exception as e: + logger.exception("Failed to list Docker images") + return DockerListImagesResponse( + success=False, error=f"Failed to list images: {str(e)}" + ) diff --git a/src/tools/cloud/e2b.py b/src/tools/cloud/e2b.py new file mode 100644 index 0000000..4fc7686 --- /dev/null +++ b/src/tools/cloud/e2b.py @@ -0,0 +1,324 @@ +"""E2B tools for running code, commands, and managing files in cloud sandboxes. + +E2B provides secure, isolated sandbox environments for executing untrusted +code. Each tool call creates a fresh sandbox that is destroyed after use. + +Environment variables: + E2B_API_KEY: API key for the E2B service. +""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.cloud.schemas import ( + E2BDownloadFileData, + E2BDownloadFileResponse, + E2BListFilesData, + E2BListFilesResponse, + E2BRunCodeData, + E2BRunCodeResponse, + E2BRunCommandData, + E2BRunCommandResponse, + E2BUploadFileData, + E2BUploadFileResponse, +) + +try: + from e2b_code_interpreter import Sandbox +except ImportError as err: + raise ImportError( + "e2b-code-interpreter is required for E2B tools. " + "Install with: pip install e2b-code-interpreter" + ) from err + +logger = logging.getLogger("humcp.tools.e2b") + + +@tool() +async def e2b_run_code( + code: str, + language: str = "python", + timeout: int = 300, +) -> E2BRunCodeResponse: + """Run code in an isolated E2B cloud sandbox. + + Executes source code in a disposable sandbox environment. The sandbox + is automatically destroyed after execution completes. + + Args: + code: Source code to execute. + language: Programming language. Defaults to 'python'. + timeout: Sandbox timeout in seconds. Defaults to 300. + + Returns: + Execution output including stdout, generated results, and any errors. + """ + try: + api_key = await resolve_credential("E2B_API_KEY") + if not api_key: + return E2BRunCodeResponse( + success=False, error="E2B API key not configured. Set E2B_API_KEY." + ) + + logger.info( + "Running code in E2B sandbox language=%s timeout=%d", language, timeout + ) + sandbox = Sandbox.create(api_key=api_key, timeout=timeout) + + try: + execution = sandbox.run_code(code) + + error_text = None + if execution.error: + error_text = ( + f"{execution.error.name}: {execution.error.value}\n" + f"{execution.error.traceback}" + ) + + results: list[str] = [] + if hasattr(execution, "logs") and execution.logs: + results.append(str(execution.logs)) + + for result in execution.results: + if hasattr(result, "text") and result.text: + results.append(result.text) + elif hasattr(result, "png") and result.png: + results.append("[PNG image generated]") + + output = "\n".join(results) if results else "Code executed with no output." + + data = E2BRunCodeData( + output=output, + language=language, + error=error_text, + ) + + logger.info("E2B code execution complete") + return E2BRunCodeResponse(success=True, data=data) + finally: + try: + sandbox.kill() + except Exception: + logger.warning("Failed to clean up E2B sandbox") + except Exception as e: + logger.exception("Failed to run code in E2B sandbox") + return E2BRunCodeResponse( + success=False, error=f"E2B execution failed: {str(e)}" + ) + + +@tool() +async def e2b_run_command( + command: str, + timeout: int = 300, +) -> E2BRunCommandResponse: + """Run a shell command in an isolated E2B cloud sandbox. + + Executes a shell command in a disposable sandbox and returns stdout, + stderr, and exit code. The sandbox is automatically destroyed afterward. + + Args: + command: Shell command to execute. + timeout: Sandbox timeout in seconds. Defaults to 300. + + Returns: + Command stdout, stderr, and exit code. + """ + try: + api_key = await resolve_credential("E2B_API_KEY") + if not api_key: + return E2BRunCommandResponse( + success=False, error="E2B API key not configured. Set E2B_API_KEY." + ) + + logger.info("Running command in E2B sandbox command=%s", command[:100]) + sandbox = Sandbox.create(api_key=api_key, timeout=timeout) + + try: + result = sandbox.commands.run(command) + + stdout = getattr(result, "stdout", "") or "" + stderr = getattr(result, "stderr", "") or "" + exit_code = getattr(result, "exit_code", None) + + data = E2BRunCommandData( + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) + + logger.info("E2B command complete exit_code=%s", exit_code) + return E2BRunCommandResponse(success=True, data=data) + finally: + try: + sandbox.kill() + except Exception: + logger.warning("Failed to clean up E2B sandbox") + except Exception as e: + logger.exception("Failed to run command in E2B sandbox") + return E2BRunCommandResponse( + success=False, error=f"E2B command failed: {str(e)}" + ) + + +@tool() +async def e2b_upload_file( + path: str, + content: str, + timeout: int = 300, +) -> E2BUploadFileResponse: + """Upload a text file to an E2B cloud sandbox. + + Creates a file at the specified path within a sandbox environment. + The sandbox is destroyed after the operation. + + Args: + path: Destination path in the sandbox (e.g. '/home/user/data.txt'). + content: Text content to write to the file. + timeout: Sandbox timeout in seconds. Defaults to 300. + + Returns: + Confirmation with the file path in the sandbox. + """ + try: + api_key = await resolve_credential("E2B_API_KEY") + if not api_key: + return E2BUploadFileResponse( + success=False, error="E2B API key not configured. Set E2B_API_KEY." + ) + + logger.info("Uploading file to E2B sandbox path=%s", path) + sandbox = Sandbox.create(api_key=api_key, timeout=timeout) + + try: + sandbox.files.write(path, content) + + data = E2BUploadFileData( + path=path, + message=f"File uploaded to {path}", + ) + + logger.info("E2B file upload complete path=%s", path) + return E2BUploadFileResponse(success=True, data=data) + finally: + try: + sandbox.kill() + except Exception: + logger.warning("Failed to clean up E2B sandbox") + except Exception as e: + logger.exception("Failed to upload file to E2B sandbox") + return E2BUploadFileResponse( + success=False, error=f"E2B file upload failed: {str(e)}" + ) + + +@tool() +async def e2b_download_file( + path: str, + timeout: int = 300, +) -> E2BDownloadFileResponse: + """Download a text file from an E2B cloud sandbox. + + Reads the content of a file at the specified path. Only suitable for + text files; binary files will produce garbled output. + + Args: + path: Path of the file in the sandbox (e.g. '/home/user/output.txt'). + timeout: Sandbox timeout in seconds. Defaults to 300. + + Returns: + File content and size. + """ + try: + api_key = await resolve_credential("E2B_API_KEY") + if not api_key: + return E2BDownloadFileResponse( + success=False, error="E2B API key not configured. Set E2B_API_KEY." + ) + + logger.info("Downloading file from E2B sandbox path=%s", path) + sandbox = Sandbox.create(api_key=api_key, timeout=timeout) + + try: + content = sandbox.files.read(path) + content_str = ( + content + if isinstance(content, str) + else content.decode("utf-8", errors="replace") + ) + + data = E2BDownloadFileData( + path=path, + content=content_str, + size=len(content_str), + ) + + logger.info( + "E2B file download complete path=%s size=%d", path, len(content_str) + ) + return E2BDownloadFileResponse(success=True, data=data) + finally: + try: + sandbox.kill() + except Exception: + logger.warning("Failed to clean up E2B sandbox") + except Exception as e: + logger.exception("Failed to download file from E2B sandbox") + return E2BDownloadFileResponse( + success=False, error=f"E2B file download failed: {str(e)}" + ) + + +@tool() +async def e2b_list_files( + path: str = "/home/user", + timeout: int = 300, +) -> E2BListFilesResponse: + """List files and directories in an E2B cloud sandbox. + + Lists the contents of a directory at the specified path in the sandbox. + + Args: + path: Directory path to list. Defaults to '/home/user'. + timeout: Sandbox timeout in seconds. Defaults to 300. + + Returns: + List of file and directory names in the specified path. + """ + try: + api_key = await resolve_credential("E2B_API_KEY") + if not api_key: + return E2BListFilesResponse( + success=False, error="E2B API key not configured. Set E2B_API_KEY." + ) + + logger.info("Listing files in E2B sandbox path=%s", path) + sandbox = Sandbox.create(api_key=api_key, timeout=timeout) + + try: + entries = sandbox.files.list(path) + file_names = [getattr(entry, "name", str(entry)) for entry in entries] + + data = E2BListFilesData( + path=path, + files=file_names, + count=len(file_names), + ) + + logger.info( + "E2B list files complete path=%s count=%d", path, len(file_names) + ) + return E2BListFilesResponse(success=True, data=data) + finally: + try: + sandbox.kill() + except Exception: + logger.warning("Failed to clean up E2B sandbox") + except Exception as e: + logger.exception("Failed to list files in E2B sandbox") + return E2BListFilesResponse( + success=False, error=f"E2B list files failed: {str(e)}" + ) diff --git a/src/tools/cloud/schemas.py b/src/tools/cloud/schemas.py new file mode 100644 index 0000000..cb07a95 --- /dev/null +++ b/src/tools/cloud/schemas.py @@ -0,0 +1,752 @@ +"""Pydantic output schemas for cloud tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# AWS Lambda Schemas +# ============================================================================= + + +class LambdaFunctionSummary(BaseModel): + """Summary of an AWS Lambda function.""" + + function_name: str = Field(..., description="Name of the Lambda function") + function_arn: str | None = Field(None, description="ARN of the Lambda function") + runtime: str | None = Field( + None, description="Runtime environment (e.g. python3.13, nodejs20.x)" + ) + handler: str | None = Field( + None, description="Function entry point (e.g. index.handler)" + ) + last_modified: str | None = Field( + None, description="Last modified timestamp in ISO 8601 format" + ) + memory_size: int | None = Field(None, description="Memory allocated in MB") + timeout: int | None = Field(None, description="Execution timeout in seconds") + code_size: int | None = Field( + None, description="Size of the deployment package in bytes" + ) + description: str | None = Field(None, description="Function description") + + +class LambdaListFunctionsData(BaseModel): + """Output data for aws_lambda_list_functions tool.""" + + functions: list[LambdaFunctionSummary] = Field( + default_factory=list, description="List of Lambda functions" + ) + count: int = Field(..., description="Total number of functions returned") + + +class LambdaInvokeData(BaseModel): + """Output data for aws_lambda_invoke tool.""" + + function_name: str = Field(..., description="Name of the invoked function") + status_code: int = Field(..., description="HTTP status code of the invocation") + payload: str = Field(..., description="Response payload from the function") + executed_version: str | None = Field( + default=None, description="Version or alias of the executed function" + ) + function_error: str | None = Field( + default=None, + description="Error type if the function returned an error (Handled or Unhandled)", + ) + log_result: str | None = Field( + default=None, + description="Last 4KB of base64-encoded execution log (when LogType=Tail)", + ) + + +class LambdaGetFunctionData(BaseModel): + """Output data for aws_lambda_get_function tool.""" + + function_name: str = Field(..., description="Name of the Lambda function") + function_arn: str = Field(..., description="ARN of the Lambda function") + runtime: str | None = Field(None, description="Runtime environment") + handler: str | None = Field(None, description="Function entry point") + role: str | None = Field(None, description="Execution role ARN") + code_size: int | None = Field( + None, description="Size of the deployment package in bytes" + ) + description: str | None = Field(None, description="Function description") + timeout: int | None = Field(None, description="Execution timeout in seconds") + memory_size: int | None = Field(None, description="Memory allocated in MB") + last_modified: str | None = Field(None, description="Last modified timestamp") + state: str | None = Field( + None, + description="Current state of the function (Active, Pending, Inactive, Failed)", + ) + last_update_status: str | None = Field( + None, description="Status of the last update (Successful, Failed, InProgress)" + ) + environment_variables: dict[str, str] | None = Field( + None, description="Environment variables configured for the function" + ) + layers: list[str] | None = Field( + None, description="List of layer ARNs attached to the function" + ) + + +class LambdaUpdateCodeData(BaseModel): + """Output data for aws_lambda_update_function_code tool.""" + + function_name: str = Field(..., description="Name of the updated function") + function_arn: str = Field(..., description="ARN of the updated function") + runtime: str | None = Field(None, description="Runtime environment") + code_size: int | None = Field( + None, description="Size of the new deployment package in bytes" + ) + last_modified: str | None = Field(None, description="Last modified timestamp") + last_update_status: str | None = Field( + None, description="Status of the update (Successful, Failed, InProgress)" + ) + + +class LambdaAliasInfo(BaseModel): + """Information about an AWS Lambda function alias.""" + + alias_arn: str = Field(..., description="ARN of the alias") + name: str = Field(..., description="Alias name") + function_version: str = Field( + ..., description="Function version the alias points to" + ) + description: str | None = Field(None, description="Alias description") + routing_config: dict[str, float] | None = Field( + None, description="Traffic-shifting routing configuration (version weights)" + ) + + +class LambdaListAliasesData(BaseModel): + """Output data for aws_lambda_list_aliases tool.""" + + function_name: str = Field(..., description="Name of the Lambda function") + aliases: list[LambdaAliasInfo] = Field( + default_factory=list, description="List of aliases" + ) + count: int = Field(..., description="Total number of aliases returned") + + +class LambdaEventSourceMapping(BaseModel): + """An event source mapping for a Lambda function.""" + + uuid: str = Field(..., description="Unique identifier of the event source mapping") + function_arn: str | None = Field(None, description="ARN of the Lambda function") + event_source_arn: str | None = Field(None, description="ARN of the event source") + state: str | None = Field( + None, description="Current state (Enabled, Disabled, Creating, etc.)" + ) + batch_size: int | None = Field( + None, description="Maximum number of records per batch" + ) + last_modified: str | None = Field(None, description="Last modified timestamp") + + +class LambdaListEventSourceMappingsData(BaseModel): + """Output data for aws_lambda_list_event_source_mappings tool.""" + + event_source_mappings: list[LambdaEventSourceMapping] = Field( + default_factory=list, description="List of event source mappings" + ) + count: int = Field(..., description="Total number of mappings returned") + + +# ============================================================================= +# AWS SES Schemas +# ============================================================================= + + +class SesSendEmailData(BaseModel): + """Output data for aws_ses_send_email tool.""" + + message_id: str = Field(..., description="SES message ID") + to: str = Field(..., description="Recipient email address") + subject: str = Field(..., description="Email subject") + + +class SesIdentityInfo(BaseModel): + """Information about an SES verified identity.""" + + identity: str = Field(..., description="Email address or domain") + identity_type: str | None = Field( + None, description="Type of identity (EmailAddress or Domain)" + ) + + +class SesListIdentitiesData(BaseModel): + """Output data for aws_ses_list_identities tool.""" + + identities: list[SesIdentityInfo] = Field( + default_factory=list, description="List of verified identities" + ) + count: int = Field(..., description="Total number of identities returned") + + +class SesSendStatisticsEntry(BaseModel): + """A single send statistics data point.""" + + timestamp: str | None = Field(None, description="Timestamp of the data point") + delivery_attempts: int = Field(0, description="Number of delivery attempts") + bounces: int = Field(0, description="Number of bounces") + complaints: int = Field(0, description="Number of complaints") + rejects: int = Field(0, description="Number of rejects") + + +class SesSendStatisticsData(BaseModel): + """Output data for aws_ses_get_send_statistics tool.""" + + statistics: list[SesSendStatisticsEntry] = Field( + default_factory=list, description="Send statistics data points" + ) + count: int = Field(..., description="Number of data points returned") + + +class SesSendTemplatedEmailData(BaseModel): + """Output data for aws_ses_send_templated_email tool.""" + + message_id: str = Field(..., description="SES message ID") + to: str = Field(..., description="Recipient email address") + template_name: str = Field(..., description="Name of the SES template used") + + +class SesVerifyEmailData(BaseModel): + """Output data for aws_ses_verify_email tool.""" + + email: str = Field(..., description="Email address that verification was sent to") + message: str = Field(..., description="Confirmation message") + + +# ============================================================================= +# Docker Schemas +# ============================================================================= + + +class DockerContainerInfo(BaseModel): + """Information about a Docker container.""" + + id: str = Field(..., description="Container ID") + name: str = Field(..., description="Container name") + image: str = Field(..., description="Image used by the container") + status: str = Field( + ..., description="Current container status (e.g. running, exited)" + ) + created: str | None = Field(None, description="Creation timestamp") + ports: dict[str, Any] | None = Field(None, description="Port mappings") + + +class DockerListContainersData(BaseModel): + """Output data for docker_list_containers tool.""" + + containers: list[DockerContainerInfo] = Field( + default_factory=list, description="List of containers" + ) + count: int = Field(..., description="Total number of containers returned") + + +class DockerRunContainerData(BaseModel): + """Output data for docker_run_container tool.""" + + container_id: str = Field(..., description="ID of the started container") + image: str = Field(..., description="Image used") + + +class DockerStopContainerData(BaseModel): + """Output data for docker_stop_container tool.""" + + container_id: str = Field(..., description="ID of the stopped container") + message: str = Field(..., description="Status message") + + +class DockerContainerLogsData(BaseModel): + """Output data for docker_get_container_logs tool.""" + + container_id: str = Field(..., description="Container ID") + logs: str = Field(..., description="Container log output") + tail: int | None = Field(None, description="Number of tail lines requested") + + +class DockerRemoveContainerData(BaseModel): + """Output data for docker_remove_container tool.""" + + container_id: str = Field(..., description="ID of the removed container") + message: str = Field(..., description="Status message") + + +class DockerInspectContainerData(BaseModel): + """Output data for docker_inspect_container tool.""" + + id: str = Field(..., description="Container ID") + name: str = Field(..., description="Container name") + image: str = Field(..., description="Image used by the container") + status: str = Field(..., description="Current container status") + created: str | None = Field(None, description="Creation timestamp") + started_at: str | None = Field(None, description="Container start timestamp") + finished_at: str | None = Field(None, description="Container stop timestamp") + platform: str | None = Field(None, description="Platform (e.g. linux/amd64)") + restart_count: int | None = Field( + None, description="Number of times the container has restarted" + ) + ports: dict[str, Any] | None = Field(None, description="Port mappings") + environment: list[str] | None = Field( + None, description="Environment variables (KEY=VALUE)" + ) + mounts: list[dict[str, Any]] | None = Field(None, description="Volume mounts") + network_mode: str | None = Field( + None, description="Network mode (bridge, host, etc.)" + ) + ip_address: str | None = Field(None, description="Container IP address") + + +class DockerImageInfo(BaseModel): + """Information about a Docker image.""" + + id: str = Field(..., description="Image ID") + tags: list[str] = Field(default_factory=list, description="Image tags") + size: int | None = Field(None, description="Image size in bytes") + created: str | None = Field(None, description="Creation timestamp") + + +class DockerListImagesData(BaseModel): + """Output data for docker_list_images tool.""" + + images: list[DockerImageInfo] = Field( + default_factory=list, description="List of Docker images" + ) + count: int = Field(..., description="Total number of images returned") + + +# ============================================================================= +# Daytona Schemas +# ============================================================================= + + +class DaytonaWorkspaceInfo(BaseModel): + """Information about a Daytona workspace.""" + + id: str = Field(..., description="Workspace ID") + name: str | None = Field(None, description="Workspace name") + state: str | None = Field(None, description="Current workspace state") + repo_url: str | None = Field(None, description="Repository URL") + + +class DaytonaListWorkspacesData(BaseModel): + """Output data for daytona_list_workspaces tool.""" + + workspaces: list[DaytonaWorkspaceInfo] = Field( + default_factory=list, description="List of workspaces" + ) + count: int = Field(..., description="Total number of workspaces returned") + + +class DaytonaCreateWorkspaceData(BaseModel): + """Output data for daytona_create_workspace tool.""" + + workspace_id: str = Field(..., description="ID of the created workspace") + repo_url: str = Field(..., description="Repository URL used") + + +class DaytonaRunCommandData(BaseModel): + """Output data for daytona_run_command tool.""" + + workspace_id: str = Field(..., description="Workspace ID") + output: str = Field(..., description="Command output") + exit_code: int | None = Field(None, description="Exit code of the command") + + +class DaytonaStartStopWorkspaceData(BaseModel): + """Output data for daytona_start_workspace and daytona_stop_workspace tools.""" + + workspace_id: str = Field(..., description="Workspace ID") + message: str = Field(..., description="Status message") + + +class DaytonaDeleteWorkspaceData(BaseModel): + """Output data for daytona_delete_workspace tool.""" + + workspace_id: str = Field(..., description="ID of the deleted workspace") + message: str = Field(..., description="Confirmation message") + + +# ============================================================================= +# E2B Schemas +# ============================================================================= + + +class E2BRunCodeData(BaseModel): + """Output data for e2b_run_code tool.""" + + output: str = Field(..., description="Code execution output") + language: str = Field(..., description="Programming language used") + error: str | None = Field(None, description="Error message if execution failed") + + +class E2BRunCommandData(BaseModel): + """Output data for e2b_run_command tool.""" + + stdout: str = Field("", description="Standard output") + stderr: str = Field("", description="Standard error") + exit_code: int | None = Field(None, description="Exit code of the command") + + +class E2BUploadFileData(BaseModel): + """Output data for e2b_upload_file tool.""" + + path: str = Field(..., description="Path of the uploaded file in the sandbox") + message: str = Field(..., description="Status message") + + +class E2BDownloadFileData(BaseModel): + """Output data for e2b_download_file tool.""" + + path: str = Field(..., description="Path of the downloaded file in the sandbox") + content: str = Field(..., description="File content (text files only)") + size: int | None = Field(None, description="File size in bytes") + + +class E2BListFilesData(BaseModel): + """Output data for e2b_list_files tool.""" + + path: str = Field(..., description="Directory path listed") + files: list[str] = Field( + default_factory=list, description="List of file/directory names" + ) + count: int = Field(..., description="Number of entries returned") + + +# ============================================================================= +# Airflow Schemas +# ============================================================================= + + +class AirflowDagInfo(BaseModel): + """Information about an Airflow DAG.""" + + dag_id: str = Field(..., description="DAG identifier") + description: str | None = Field(None, description="DAG description") + is_paused: bool | None = Field(None, description="Whether the DAG is paused") + is_active: bool | None = Field(None, description="Whether the DAG is active") + file_token: str | None = Field(None, description="File token for the DAG file") + owners: list[str] | None = Field(None, description="List of DAG owners") + schedule_interval: str | None = Field( + None, description="Schedule interval expression (e.g. '@daily', '0 * * * *')" + ) + tags: list[str] | None = Field(None, description="DAG tags") + + +class AirflowListDagsData(BaseModel): + """Output data for airflow_list_dags tool.""" + + dags: list[AirflowDagInfo] = Field(default_factory=list, description="List of DAGs") + count: int = Field(..., description="Total number of DAGs returned") + + +class AirflowTriggerDagData(BaseModel): + """Output data for airflow_trigger_dag tool.""" + + dag_id: str = Field(..., description="DAG identifier") + dag_run_id: str = Field(..., description="DAG run identifier") + state: str | None = Field(None, description="Initial state of the DAG run") + execution_date: str | None = Field(None, description="Logical date of the DAG run") + + +class AirflowDagRunData(BaseModel): + """Output data for airflow_get_dag_run tool.""" + + dag_id: str = Field(..., description="DAG identifier") + dag_run_id: str = Field(..., description="DAG run identifier") + state: str = Field( + ..., description="Current state of the DAG run (running, success, failed)" + ) + execution_date: str | None = Field(None, description="Logical date") + start_date: str | None = Field(None, description="Actual start date") + end_date: str | None = Field(None, description="Actual end date") + + +class AirflowTaskInstanceInfo(BaseModel): + """Information about an Airflow task instance within a DAG run.""" + + task_id: str = Field(..., description="Task identifier") + state: str | None = Field( + None, description="Task state (success, running, failed, etc.)" + ) + start_date: str | None = Field(None, description="Task start date") + end_date: str | None = Field(None, description="Task end date") + duration: float | None = Field(None, description="Task duration in seconds") + try_number: int | None = Field(None, description="Current try number") + operator: str | None = Field(None, description="Operator class name") + + +class AirflowListTaskInstancesData(BaseModel): + """Output data for airflow_list_task_instances tool.""" + + dag_id: str = Field(..., description="DAG identifier") + dag_run_id: str = Field(..., description="DAG run identifier") + task_instances: list[AirflowTaskInstanceInfo] = Field( + default_factory=list, description="List of task instances" + ) + count: int = Field(..., description="Total number of task instances returned") + + +class AirflowPauseDagData(BaseModel): + """Output data for airflow_pause_dag and airflow_unpause_dag tools.""" + + dag_id: str = Field(..., description="DAG identifier") + is_paused: bool = Field(..., description="Whether the DAG is now paused") + + +# ============================================================================= +# Zoom Schemas +# ============================================================================= + + +class ZoomRecordingFile(BaseModel): + """A single recording file from a Zoom meeting.""" + + id: str | None = Field(None, description="Recording file ID") + file_type: str | None = Field( + None, description="File type (MP4, M4A, CHAT, TRANSCRIPT, etc.)" + ) + file_size: int | None = Field(None, description="File size in bytes") + download_url: str | None = Field(None, description="URL to download the recording") + play_url: str | None = Field(None, description="URL to play the recording") + status: str | None = Field(None, description="Processing status of the recording") + recording_start: str | None = Field(None, description="Recording start time") + recording_end: str | None = Field(None, description="Recording end time") + + +class ZoomRecordingMeeting(BaseModel): + """A Zoom meeting with its recordings.""" + + meeting_id: str = Field(..., description="Meeting ID") + topic: str | None = Field(None, description="Meeting topic/title") + start_time: str | None = Field(None, description="Meeting start time") + duration: int | None = Field(None, description="Meeting duration in minutes") + total_size: int | None = Field( + None, description="Total size of all recording files in bytes" + ) + recording_count: int = Field(0, description="Number of recording files") + recording_files: list[ZoomRecordingFile] = Field( + default_factory=list, description="List of recording files" + ) + + +class ZoomListRecordingsData(BaseModel): + """Output data for zoom_list_recordings tool.""" + + user_id: str = Field(..., description="Zoom user ID or email queried") + from_date: str | None = Field(None, description="Start date of the query range") + to_date: str | None = Field(None, description="End date of the query range") + meetings: list[ZoomRecordingMeeting] = Field( + default_factory=list, description="List of meetings with recordings" + ) + total_records: int = Field( + 0, description="Total number of meetings with recordings" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class LambdaListFunctionsResponse(ToolResponse[LambdaListFunctionsData]): + """Response schema for aws_lambda_list_functions tool.""" + + pass + + +class LambdaInvokeResponse(ToolResponse[LambdaInvokeData]): + """Response schema for aws_lambda_invoke tool.""" + + pass + + +class LambdaGetFunctionResponse(ToolResponse[LambdaGetFunctionData]): + """Response schema for aws_lambda_get_function tool.""" + + pass + + +class LambdaUpdateCodeResponse(ToolResponse[LambdaUpdateCodeData]): + """Response schema for aws_lambda_update_function_code tool.""" + + pass + + +class LambdaListEventSourceMappingsResponse( + ToolResponse[LambdaListEventSourceMappingsData] +): + """Response schema for aws_lambda_list_event_source_mappings tool.""" + + pass + + +class LambdaListAliasesResponse(ToolResponse[LambdaListAliasesData]): + """Response schema for aws_lambda_list_aliases tool.""" + + pass + + +class SesSendEmailResponse(ToolResponse[SesSendEmailData]): + """Response schema for aws_ses_send_email tool.""" + + pass + + +class SesListIdentitiesResponse(ToolResponse[SesListIdentitiesData]): + """Response schema for aws_ses_list_identities tool.""" + + pass + + +class SesSendStatisticsResponse(ToolResponse[SesSendStatisticsData]): + """Response schema for aws_ses_get_send_statistics tool.""" + + pass + + +class SesSendTemplatedEmailResponse(ToolResponse[SesSendTemplatedEmailData]): + """Response schema for aws_ses_send_templated_email tool.""" + + pass + + +class SesVerifyEmailResponse(ToolResponse[SesVerifyEmailData]): + """Response schema for aws_ses_verify_email tool.""" + + pass + + +class DockerListContainersResponse(ToolResponse[DockerListContainersData]): + """Response schema for docker_list_containers tool.""" + + pass + + +class DockerRunContainerResponse(ToolResponse[DockerRunContainerData]): + """Response schema for docker_run_container tool.""" + + pass + + +class DockerStopContainerResponse(ToolResponse[DockerStopContainerData]): + """Response schema for docker_stop_container tool.""" + + pass + + +class DockerContainerLogsResponse(ToolResponse[DockerContainerLogsData]): + """Response schema for docker_get_container_logs tool.""" + + pass + + +class DockerRemoveContainerResponse(ToolResponse[DockerRemoveContainerData]): + """Response schema for docker_remove_container tool.""" + + pass + + +class DockerInspectContainerResponse(ToolResponse[DockerInspectContainerData]): + """Response schema for docker_inspect_container tool.""" + + pass + + +class DockerListImagesResponse(ToolResponse[DockerListImagesData]): + """Response schema for docker_list_images tool.""" + + pass + + +class DaytonaListWorkspacesResponse(ToolResponse[DaytonaListWorkspacesData]): + """Response schema for daytona_list_workspaces tool.""" + + pass + + +class DaytonaCreateWorkspaceResponse(ToolResponse[DaytonaCreateWorkspaceData]): + """Response schema for daytona_create_workspace tool.""" + + pass + + +class DaytonaRunCommandResponse(ToolResponse[DaytonaRunCommandData]): + """Response schema for daytona_run_command tool.""" + + pass + + +class DaytonaStartStopWorkspaceResponse(ToolResponse[DaytonaStartStopWorkspaceData]): + """Response schema for daytona_start_workspace and daytona_stop_workspace tools.""" + + pass + + +class DaytonaDeleteWorkspaceResponse(ToolResponse[DaytonaDeleteWorkspaceData]): + """Response schema for daytona_delete_workspace tool.""" + + pass + + +class E2BRunCodeResponse(ToolResponse[E2BRunCodeData]): + """Response schema for e2b_run_code tool.""" + + pass + + +class E2BRunCommandResponse(ToolResponse[E2BRunCommandData]): + """Response schema for e2b_run_command tool.""" + + pass + + +class E2BUploadFileResponse(ToolResponse[E2BUploadFileData]): + """Response schema for e2b_upload_file tool.""" + + pass + + +class E2BDownloadFileResponse(ToolResponse[E2BDownloadFileData]): + """Response schema for e2b_download_file tool.""" + + pass + + +class E2BListFilesResponse(ToolResponse[E2BListFilesData]): + """Response schema for e2b_list_files tool.""" + + pass + + +class AirflowListDagsResponse(ToolResponse[AirflowListDagsData]): + """Response schema for airflow_list_dags tool.""" + + pass + + +class AirflowTriggerDagResponse(ToolResponse[AirflowTriggerDagData]): + """Response schema for airflow_trigger_dag tool.""" + + pass + + +class AirflowDagRunResponse(ToolResponse[AirflowDagRunData]): + """Response schema for airflow_get_dag_run tool.""" + + pass + + +class AirflowListTaskInstancesResponse(ToolResponse[AirflowListTaskInstancesData]): + """Response schema for airflow_list_task_instances tool.""" + + pass + + +class AirflowPauseDagResponse(ToolResponse[AirflowPauseDagData]): + """Response schema for airflow_pause_dag and airflow_unpause_dag tools.""" + + pass diff --git a/src/tools/data/schemas.py b/src/tools/data/schemas.py new file mode 100644 index 0000000..a2d6c02 --- /dev/null +++ b/src/tools/data/schemas.py @@ -0,0 +1,210 @@ +"""Pydantic output schemas for CSV and pandas data tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# CSV Tool Data Schemas +# ============================================================================= + + +class ListCSVFilesData(BaseModel): + """Output data for list_csv_files tool.""" + + files: list[str] = Field(..., description="List of available CSV file names") + count: int = Field(..., description="Number of available CSV files") + + +class ReadCSVFileData(BaseModel): + """Output data for read_csv_file tool.""" + + rows: list[dict[str, Any]] = Field(..., description="CSV rows as dictionaries") + row_count: int = Field(..., description="Number of rows returned") + + +class GetCSVColumnsData(BaseModel): + """Output data for get_csv_columns tool.""" + + columns: list[str] = Field(..., description="List of column names") + column_count: int = Field(..., description="Number of columns") + + +class QueryCSVFileData(BaseModel): + """Output data for query_csv_file tool.""" + + rows: list[dict[str, Any]] = Field(..., description="Query result rows") + row_count: int = Field(..., description="Number of rows returned") + columns: list[str] = Field(..., description="Column names in result") + + +class DescribeCSVFileData(BaseModel): + """Output data for describe_csv_file tool.""" + + file_name: str = Field(..., description="Name of the CSV file") + file_size_bytes: int = Field(..., description="File size in bytes") + columns: list[str] = Field(..., description="List of column names") + column_count: int = Field(..., description="Number of columns") + row_count: int = Field(..., description="Approximate number of rows (up to 1000)") + sample_rows: list[dict[str, Any]] = Field(..., description="First 5 sample rows") + + +class AddCSVFileData(BaseModel): + """Output data for add_csv_file tool.""" + + message: str = Field(..., description="Success message") + file_name: str = Field(..., description="Name of the added file (stem)") + + +class RemoveCSVFileData(BaseModel): + """Output data for remove_csv_file tool.""" + + message: str = Field(..., description="Success message") + + +# ============================================================================= +# CSV Response Wrappers +# ============================================================================= + + +class ListCSVFilesResponse(ToolResponse[ListCSVFilesData]): + """Response schema for list_csv_files tool.""" + + pass + + +class ReadCSVFileResponse(ToolResponse[ReadCSVFileData]): + """Response schema for read_csv_file tool.""" + + pass + + +class GetCSVColumnsResponse(ToolResponse[GetCSVColumnsData]): + """Response schema for get_csv_columns tool.""" + + pass + + +class QueryCSVFileResponse(ToolResponse[QueryCSVFileData]): + """Response schema for query_csv_file tool.""" + + pass + + +class DescribeCSVFileResponse(ToolResponse[DescribeCSVFileData]): + """Response schema for describe_csv_file tool.""" + + pass + + +class AddCSVFileResponse(ToolResponse[AddCSVFileData]): + """Response schema for add_csv_file tool.""" + + pass + + +class RemoveCSVFileResponse(ToolResponse[RemoveCSVFileData]): + """Response schema for remove_csv_file tool.""" + + pass + + +# ============================================================================= +# Pandas DataFrame Tool Data Schemas +# ============================================================================= + + +class CreateDataFrameData(BaseModel): + """Output data for create_pandas_dataframe tool.""" + + name: str = Field(..., description="Name assigned to the DataFrame") + shape: tuple[int, int] = Field(..., description="DataFrame shape (rows, columns)") + columns: list[str] = Field(..., description="List of column names") + dtypes: dict[str, str] = Field(..., description="Column data types") + memory_usage: int = Field(..., description="Memory usage in bytes") + + +class DataFrameOperationData(BaseModel): + """Output data for run_dataframe_operation tool.""" + + operation: str = Field(..., description="Operation that was performed") + dataframe: str = Field(..., description="Name of the DataFrame") + result: str = Field(..., description="Operation result as string") + + +class DataFrameInfo(BaseModel): + """Information about a stored DataFrame.""" + + name: str = Field(..., description="DataFrame name") + shape: tuple[int, int] = Field(..., description="DataFrame shape") + columns: list[str] = Field(..., description="Column names") + memory_usage_bytes: int = Field(..., description="Memory usage in bytes") + + +class DataFrameDetailedInfo(BaseModel): + """Detailed information about a DataFrame.""" + + name: str = Field(..., description="DataFrame name") + shape: tuple[int, int] = Field(..., description="DataFrame shape") + columns: list[str] = Field(..., description="Column names") + dtypes: dict[str, str] = Field(..., description="Column data types") + memory_usage_bytes: int = Field(..., description="Memory usage in bytes") + null_counts: dict[str, int] = Field(..., description="Null counts per column") + sample_data: list[dict[str, Any]] = Field(..., description="Sample rows") + + +class DeleteDataFrameData(BaseModel): + """Output data for delete_dataframe tool.""" + + message: str = Field(..., description="Success message") + + +class ExportDataFrameData(BaseModel): + """Output data for export_dataframe tool.""" + + message: str = Field(..., description="Success message") + output: str = Field(..., description="Output path or location") + function: str = Field(..., description="Export function used") + + +# ============================================================================= +# Pandas Response Wrappers +# ============================================================================= + + +class CreateDataFrameResponse(ToolResponse[CreateDataFrameData]): + """Response schema for create_pandas_dataframe tool.""" + + pass + + +class DataFrameOperationResponse(ToolResponse[DataFrameOperationData]): + """Response schema for run_dataframe_operation tool.""" + + pass + + +class ListDataFramesResponse(ToolResponse[list[DataFrameInfo]]): + """Response schema for list_dataframes tool.""" + + pass + + +class GetDataFrameInfoResponse(ToolResponse[DataFrameDetailedInfo]): + """Response schema for get_dataframe_info tool.""" + + pass + + +class DeleteDataFrameResponse(ToolResponse[DeleteDataFrameData]): + """Response schema for delete_dataframe tool.""" + + pass + + +class ExportDataFrameResponse(ToolResponse[ExportDataFrameData]): + """Response schema for export_dataframe tool.""" + + pass diff --git a/src/tools/database/SKILL.md b/src/tools/database/SKILL.md new file mode 100644 index 0000000..1b98dec --- /dev/null +++ b/src/tools/database/SKILL.md @@ -0,0 +1,79 @@ +--- +name: querying-postgresql-database +description: Connects to PostgreSQL databases to execute SQL queries and explore schema. Use when the user asks to query a database, list tables, describe table structure, or perform any database operations. +--- + +# PostgreSQL Database Tools + +Tools for connecting to and querying PostgreSQL databases using SQLAlchemy async. + +## Setup + +Set the `DATABASE_URL` environment variable: + +```bash +DATABASE_URL=postgresql://user:password@localhost:5432/mydb +``` + +## Execute SQL Queries + +```python +# SELECT query - returns all matching rows +result = await execute_query("SELECT * FROM users") +# Returns: {"success": True, "data": {"rows": [...], "row_count": N}} +# NOTE: Do NOT add LIMIT unless the user explicitly asks to limit results + +# INSERT/UPDATE/DELETE - returns status +result = await execute_query("INSERT INTO users (name) VALUES ('Alice')") +# Returns: {"success": True, "data": {"status": "INSERT 0 1", "message": "..."}} + +# Complex queries with CTEs +result = await execute_query(""" + WITH active_users AS ( + SELECT * FROM users WHERE status = 'active' + ) + SELECT * FROM active_users WHERE created_at > '2024-01-01' +""") +``` + +## List Tables + +```python +# List tables in public schema (default) +result = await list_tables() +# Returns: {"success": True, "data": {"schema": "public", "tables": ["users", "orders"], "count": 2}} + +# List tables in specific schema +result = await list_tables(schema="analytics") +``` + +## Describe Table Structure + +```python +result = await describe_table("users") +# Returns column definitions: +# { +# "success": True, +# "data": { +# "schema": "public", +# "table": "users", +# "columns": [ +# {"name": "id", "type": "integer", "nullable": False, ...}, +# {"name": "email", "type": "character varying", "nullable": False, ...} +# ], +# "column_count": 2 +# } +# } + +# Describe table in specific schema +result = await describe_table("events", schema="analytics") +``` + +## Error Handling + +All tools return `{"success": False, "error": "..."}` on failure: +- Missing DATABASE_URL environment variable +- Connection failures +- SQL syntax errors +- Permission denied +- Table not found diff --git a/src/tools/database/__init__.py b/src/tools/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/database/duckdb.py b/src/tools/database/duckdb.py new file mode 100644 index 0000000..bed845b --- /dev/null +++ b/src/tools/database/duckdb.py @@ -0,0 +1,211 @@ +"""DuckDB database tools for querying and listing tables.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.database.schemas import ( + DuckDbListTablesData, + DuckDbListTablesResponse, + DuckDbQueryData, + DuckDbQueryResponse, + DuckDbReadFileData, + DuckDbReadFileResponse, +) + +try: + import duckdb +except ImportError as err: + raise ImportError( + "duckdb is required for DuckDB tools. Install with: pip install duckdb" + ) from err + +logger = logging.getLogger("humcp.tools.database.duckdb") + + +def _get_connection(database_path: str | None) -> duckdb.DuckDBPyConnection: + """Create a DuckDB connection. + + Args: + database_path: Path to the DuckDB database file, or None for in-memory. + + Returns: + A DuckDB connection instance. + """ + kwargs: dict[str, Any] = {} + if database_path: + kwargs["database"] = database_path + return duckdb.connect(**kwargs) + + +@tool() +async def duckdb_query( + query: str, + database_path: str | None = None, +) -> DuckDbQueryResponse: + """Execute a SQL query against a DuckDB database. + + Runs any SQL query (SELECT, INSERT, CREATE, etc.) and returns the results. + For SELECT queries, returns rows as a list of dictionaries. + For other queries, returns a success message. + + Args: + query: The SQL query to execute. + database_path: Path to the DuckDB database file. If not provided, uses an in-memory database. + + Returns: + Query results or success message. + """ + try: + await require_auth() + + logger.info("Executing DuckDB query: %s", query) + + # Remove backticks and only run the first statement + formatted_sql = query.replace("`", "") + formatted_sql = formatted_sql.split(";")[0] + + conn = _get_connection(database_path) + try: + result = conn.sql(formatted_sql) + + if result is None: + return DuckDbQueryResponse( + success=True, + data=DuckDbQueryData( + columns=[], + rows=[], + row_count=0, + ), + ) + + columns = result.columns + raw_rows = result.fetchall() + rows = [dict(zip(columns, row, strict=False)) for row in raw_rows] + + return DuckDbQueryResponse( + success=True, + data=DuckDbQueryData( + columns=columns, + rows=rows, + row_count=len(rows), + ), + ) + finally: + conn.close() + + except duckdb.Error as e: + logger.exception("DuckDB query failed") + return DuckDbQueryResponse(success=False, error=f"DuckDB error: {e}") + except Exception as e: + logger.exception("DuckDB query failed") + return DuckDbQueryResponse(success=False, error=str(e)) + + +@tool() +async def duckdb_list_tables( + database_path: str | None = None, +) -> DuckDbListTablesResponse: + """List all tables in a DuckDB database. + + Args: + database_path: Path to the DuckDB database file. If not provided, uses an in-memory database. + + Returns: + List of table names in the database. + """ + try: + await require_auth() + + logger.info("Listing DuckDB tables for database: %s", database_path) + + conn = _get_connection(database_path) + try: + result = conn.sql("SHOW TABLES") + tables: list[str] = [] + if result is not None: + raw_rows = result.fetchall() + tables = [str(row[0]) for row in raw_rows] + + return DuckDbListTablesResponse( + success=True, + data=DuckDbListTablesData( + tables=tables, + count=len(tables), + database_path=database_path, + ), + ) + finally: + conn.close() + + except duckdb.Error as e: + logger.exception("DuckDB list tables failed") + return DuckDbListTablesResponse(success=False, error=f"DuckDB error: {e}") + except Exception as e: + logger.exception("DuckDB list tables failed") + return DuckDbListTablesResponse(success=False, error=str(e)) + + +@tool() +async def duckdb_read_file( + file_path: str, + limit: int = 100, +) -> DuckDbReadFileResponse: + """Read data from a Parquet, CSV, or JSON file using DuckDB. + + DuckDB auto-detects the file format based on the extension. Supported + formats include CSV, Parquet, JSON, and JSON Lines. + + Args: + file_path: Path to the file to read (.csv, .parquet, .json, .jsonl). + limit: Maximum number of rows to return. Default 100. + + Returns: + Rows from the file with column names and row count. + """ + try: + await require_auth() + + logger.info("DuckDB reading file: %s limit=%d", file_path, limit) + + conn = _get_connection(None) + try: + safe_limit = max(1, min(limit, 10000)) + result = conn.sql(f"SELECT * FROM '{file_path}' LIMIT {safe_limit}") + + if result is None: + return DuckDbReadFileResponse( + success=True, + data=DuckDbReadFileData( + file_path=file_path, + columns=[], + rows=[], + row_count=0, + ), + ) + + columns = result.columns + raw_rows = result.fetchall() + rows = [dict(zip(columns, row, strict=False)) for row in raw_rows] + + return DuckDbReadFileResponse( + success=True, + data=DuckDbReadFileData( + file_path=file_path, + columns=columns, + rows=rows, + row_count=len(rows), + ), + ) + finally: + conn.close() + + except duckdb.Error as e: + logger.exception("DuckDB read file failed") + return DuckDbReadFileResponse(success=False, error=f"DuckDB error: {e}") + except Exception as e: + logger.exception("DuckDB read file failed") + return DuckDbReadFileResponse(success=False, error=str(e)) diff --git a/src/tools/database/neo4j.py b/src/tools/database/neo4j.py new file mode 100644 index 0000000..c33dc71 --- /dev/null +++ b/src/tools/database/neo4j.py @@ -0,0 +1,157 @@ +"""Neo4j graph database tools for Cypher queries and schema inspection.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.database.schemas import ( + Neo4jQueryData, + Neo4jQueryResponse, + Neo4jSchemaData, + Neo4jSchemaResponse, +) + +try: + from neo4j import GraphDatabase +except ImportError as err: + raise ImportError( + "neo4j is required for Neo4j tools. Install with: pip install neo4j" + ) from err + +logger = logging.getLogger("humcp.tools.database.neo4j") + + +def _get_driver(username: str, password: str): + """Create a Neo4j driver with the provided credentials. + + Args: + username: Neo4j username. + password: Neo4j password. + + Returns: + A Neo4j driver instance. + """ + uri = os.getenv("NEO4J_URI", "bolt://localhost:7687") + return GraphDatabase.driver(uri, auth=(username, password)) + + +@tool() +async def neo4j_query( + cypher_query: str, + parameters: dict[str, Any] | None = None, +) -> Neo4jQueryResponse: + """Execute a Cypher query against the Neo4j database. + + Runs any Cypher query and returns the results as a list of records. + + Args: + cypher_query: The Cypher query string to execute. + parameters: Optional dictionary of query parameters. + + Returns: + Query results as a list of record dictionaries. + """ + try: + await require_auth() + + username = await resolve_credential("NEO4J_USERNAME") + password = await resolve_credential("NEO4J_PASSWORD") + if not username or not password: + return Neo4jQueryResponse( + success=False, + error="NEO4J_USERNAME and NEO4J_PASSWORD are required.", + ) + + logger.info("Executing Cypher query: %s", cypher_query) + + driver = _get_driver(username, password) + try: + with driver.session() as session: + result = session.run( + cypher_query, + parameters=parameters or {}, + ) + records = result.data() + + return Neo4jQueryResponse( + success=True, + data=Neo4jQueryData( + records=records, + record_count=len(records), + ), + ) + finally: + driver.close() + + except ValueError as e: + return Neo4jQueryResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Neo4j query failed") + return Neo4jQueryResponse(success=False, error=f"Neo4j error: {e}") + + +@tool() +async def neo4j_get_schema() -> Neo4jSchemaResponse: + """Retrieve the schema of the Neo4j database. + + Returns node labels, relationship types, and a schema visualization + of the connected Neo4j database. + + Returns: + Database schema including labels, relationship types, and visualization data. + """ + try: + await require_auth() + + username = await resolve_credential("NEO4J_USERNAME") + password = await resolve_credential("NEO4J_PASSWORD") + if not username or not password: + return Neo4jSchemaResponse( + success=False, + error="NEO4J_USERNAME and NEO4J_PASSWORD are required.", + ) + + logger.info("Retrieving Neo4j schema") + + driver = _get_driver(username, password) + try: + with driver.session() as session: + # Get node labels + labels_result = session.run("CALL db.labels()") + labels = [record["label"] for record in labels_result] + + # Get relationship types + rel_result = session.run("CALL db.relationshipTypes()") + relationship_types = [ + record["relationshipType"] for record in rel_result + ] + + # Get schema visualization + try: + viz_result = session.run("CALL db.schema.visualization()") + schema_visualization = viz_result.data() + except Exception: + logger.warning("db.schema.visualization() not available, skipping") + schema_visualization = [] + + return Neo4jSchemaResponse( + success=True, + data=Neo4jSchemaData( + labels=labels, + relationship_types=relationship_types, + schema_visualization=schema_visualization, + ), + ) + finally: + driver.close() + + except ValueError as e: + return Neo4jSchemaResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Neo4j get schema failed") + return Neo4jSchemaResponse(success=False, error=f"Neo4j error: {e}") diff --git a/src/tools/database/postgres.py b/src/tools/database/postgres.py new file mode 100644 index 0000000..9f73c69 --- /dev/null +++ b/src/tools/database/postgres.py @@ -0,0 +1,299 @@ +"""PostgreSQL database tools using SQLAlchemy async.""" + +from __future__ import annotations + +import logging +import os +import re + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.database.schemas import ( + ColumnInfo, + DescribeTableData, + DescribeTableResponse, + ExecuteQueryModifyData, + ExecuteQueryResponse, + ExecuteQuerySelectData, + ListTablesData, + ListTablesResponse, +) + +# Engine (lazy initialized) +_engine: AsyncEngine | None = None + +logger = logging.getLogger("humcp.tools.database.postgres") + + +# Pattern for valid SQL identifiers (alphanumeric and underscore, cannot start with digit) +_VALID_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +def _is_valid_identifier(name: str) -> bool: + """Check if a string is a valid SQL identifier. + + Args: + name: The identifier to validate. + + Returns: + True if valid, False otherwise. + """ + return bool(_VALID_IDENTIFIER_PATTERN.match(name)) and len(name) <= 128 + + +def _extract_table_name(query_upper: str) -> str | None: + """Extract table name from INSERT, UPDATE, or DELETE queries. + + Args: + query_upper: The uppercase SQL query string. + + Returns: + The table name or None if not found or invalid. + """ + parts = query_upper.split() + if len(parts) < 2: + return None + + table_name = None + if parts[0] == "UPDATE": + # UPDATE table_name SET ... + table_name = parts[1] + elif parts[0] == "INSERT" and len(parts) >= 3 and parts[1] == "INTO": + # INSERT INTO table_name ... + table_name = parts[2] + elif parts[0] == "DELETE" and len(parts) >= 3 and parts[1] == "FROM": + # DELETE FROM table_name ... + table_name = parts[2] + + # Validate table name to prevent SQL injection + if table_name and _is_valid_identifier(table_name): + return table_name + + return None + + +def _get_engine() -> AsyncEngine: + """Get or create the SQLAlchemy async engine.""" + global _engine + if _engine is None: + database_url = os.getenv("DATABASE_URL") + if not database_url: + raise ValueError("DATABASE_URL environment variable is not set") + # Convert postgresql:// to postgresql+asyncpg:// for async support + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) + + # Log sanitized URL (hide credentials) + sanitized_url = database_url.split("@")[-1] if "@" in database_url else "***" + logger.info( + "Creating database engine for postgresql+asyncpg://***@%s", sanitized_url + ) + _engine = create_async_engine(database_url, pool_size=5, max_overflow=10) + return _engine + + +@tool() +async def execute_query(query: str) -> ExecuteQueryResponse: + """Execute a SQL query against the PostgreSQL database. Read and write to database + + Executes any SQL query (SELECT, INSERT, UPDATE, DELETE, DDL) and returns + the results. For SELECT queries, returns rows as a list of dictionaries. + For other queries, returns the number of affected rows. + + Args: + query: The SQL query to execute. + + Returns: + Query results or affected row count. + """ + try: + await require_auth() + + # SQL safety checks + query_stripped = query.strip() + first_word = query_stripped.split()[0].upper() if query_stripped else "" + blocked = {"DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE"} + if first_word in blocked: + return ExecuteQueryResponse( + success=False, error=f"Blocked query type: {first_word}" + ) + if os.getenv("DB_READ_ONLY", "true").lower() == "true": + if first_word not in ("SELECT", "WITH"): + return ExecuteQueryResponse( + success=False, + error="Read-only mode: only SELECT/WITH queries allowed. Set DB_READ_ONLY=false to allow writes.", + ) + + engine = _get_engine() + async with engine.connect() as conn: + logger.info("Executing query: %s", query) + result = await conn.execute(text(query)) + + # Check if it's a SELECT query (returns rows) + query_upper = query.strip().upper() + if query_upper.startswith("SELECT") or query_upper.startswith("WITH"): + rows = [dict(row._mapping) for row in result.fetchall()] + return ExecuteQueryResponse( + success=True, + data=ExecuteQuerySelectData(rows=rows, row_count=len(rows)), + ) + else: + # For INSERT/UPDATE/DELETE/DDL, commit and return rowcount + await conn.commit() + affected_rows = result.rowcount + + # Extract table name and fetch updated table contents + table_name = _extract_table_name(query_upper) + if table_name: + select_result = await conn.execute( + text(f"SELECT * FROM {table_name}") + ) + rows = [dict(row._mapping) for row in select_result.fetchall()] + # Convert rows to string representation + rows_str = "\n".join(str(row) for row in rows) + return ExecuteQueryResponse( + success=True, + data=ExecuteQueryModifyData( + affected_rows=affected_rows, + message="Query executed successfully", + table_contents=rows_str, + ), + ) + + return ExecuteQueryResponse( + success=True, + data=ExecuteQueryModifyData( + affected_rows=affected_rows, + message="Query executed successfully", + ), + ) + + except SQLAlchemyError as e: + return ExecuteQueryResponse(success=False, error=f"Database error: {e}") + except ValueError as e: + return ExecuteQueryResponse(success=False, error=str(e)) + except Exception as e: + return ExecuteQueryResponse(success=False, error=f"Error: {e}") + + +@tool() +async def list_tables(schema: str = "public") -> ListTablesResponse: + """List all tables in the specified schema. + + Args: + schema: The database schema to list tables from (default: "public"). + + Returns: + List of table names in the schema. + """ + try: + await require_auth() + + engine = _get_engine() + async with engine.connect() as conn: + result = await conn.execute( + text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = :schema + AND table_type = 'BASE TABLE' + ORDER BY table_name + """), + {"schema": schema}, + ) + tables = [row[0] for row in result.fetchall()] + return ListTablesResponse( + success=True, + data=ListTablesData( + schema_name=schema, + tables=tables, + count=len(tables), + ), + ) + except SQLAlchemyError as e: + return ListTablesResponse(success=False, error=f"Database error: {e}") + except ValueError as e: + return ListTablesResponse(success=False, error=str(e)) + except Exception as e: + return ListTablesResponse(success=False, error=f"Error: {e}") + + +@tool() +async def describe_table( + table_name: str, schema: str = "public" +) -> DescribeTableResponse: + """Get column information for a table. + + Returns column names, data types, nullability, and default values + for all columns in the specified table. + + Args: + table_name: The name of the table to describe. + schema: The database schema containing the table (default: "public"). + + Returns: + Column definitions for the table. + """ + try: + await require_auth() + + engine = _get_engine() + async with engine.connect() as conn: + result = await conn.execute( + text(""" + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = :schema + AND table_name = :table_name + ORDER BY ordinal_position + """), + {"schema": schema, "table_name": table_name}, + ) + rows = result.fetchall() + + if not rows: + return DescribeTableResponse( + success=False, error=f"Table '{schema}.{table_name}' not found" + ) + + columns = [ + ColumnInfo( + name=row[0], + type=row[1], + nullable=row[2] == "YES", + default=row[3], + max_length=row[4], + precision=row[5], + scale=row[6], + ) + for row in rows + ] + + return DescribeTableResponse( + success=True, + data=DescribeTableData( + schema_name=schema, + table=table_name, + columns=columns, + column_count=len(columns), + ), + ) + except SQLAlchemyError as e: + return DescribeTableResponse(success=False, error=f"Database error: {e}") + except ValueError as e: + return DescribeTableResponse(success=False, error=str(e)) + except Exception as e: + return DescribeTableResponse(success=False, error=f"Error: {e}") diff --git a/src/tools/database/redshift.py b/src/tools/database/redshift.py new file mode 100644 index 0000000..e731afe --- /dev/null +++ b/src/tools/database/redshift.py @@ -0,0 +1,141 @@ +"""Amazon Redshift database tools for query execution.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.database.schemas import ( + RedshiftQueryData, + RedshiftQueryResponse, +) + +try: + import redshift_connector +except ImportError as err: + raise ImportError( + "redshift_connector is required for Redshift tools. " + "Install with: pip install redshift-connector" + ) from err + +logger = logging.getLogger("humcp.tools.database.redshift") + + +def _get_connection( + host: str, + user: str, + password: str, + db: str, +) -> redshift_connector.Connection: + """Create a Redshift connection with the provided credentials. + + Args: + host: Redshift cluster host. + user: Database username. + password: Database password. + db: Database name. + + Returns: + A Redshift connection instance. + """ + return redshift_connector.connect( + host=host, + port=int(os.getenv("REDSHIFT_PORT", "5439")), + database=db, + user=user, + password=password, + ssl=True, + ) + + +@tool() +async def redshift_query( + query: str, + database: str | None = None, +) -> RedshiftQueryResponse: + """Execute a SQL query against an Amazon Redshift database. + + Runs any SQL query and returns the results. For SELECT queries, returns + rows as a list of dictionaries. For modification queries, returns a + success message. + + Args: + query: The SQL query to execute. + database: Optional database name. Falls back to REDSHIFT_DATABASE env var. + + Returns: + Query results with columns and rows. + """ + try: + await require_auth() + + host = await resolve_credential("REDSHIFT_HOST") + user = await resolve_credential("REDSHIFT_USER") + password = await resolve_credential("REDSHIFT_PASSWORD") + db = database or await resolve_credential("REDSHIFT_DATABASE") + + if not host: + return RedshiftQueryResponse( + success=False, error="REDSHIFT_HOST is required." + ) + if not user: + return RedshiftQueryResponse( + success=False, error="REDSHIFT_USER is required." + ) + if not password: + return RedshiftQueryResponse( + success=False, error="REDSHIFT_PASSWORD is required." + ) + if not db: + return RedshiftQueryResponse( + success=False, + error="Database name is required. Provide database parameter or set REDSHIFT_DATABASE.", + ) + + logger.info("Executing Redshift query: %s", query) + + conn = _get_connection(host, user, password, db) + try: + with conn.cursor() as cursor: + cursor.execute(query) + + if cursor.description is None: + return RedshiftQueryResponse( + success=True, + data=RedshiftQueryData( + columns=[], + rows=[], + row_count=0, + message="Query executed successfully.", + ), + ) + + columns = [desc[0] for desc in cursor.description] + raw_rows = cursor.fetchall() + rows: list[dict[str, Any]] = [ + dict(zip(columns, row, strict=False)) for row in raw_rows + ] + + return RedshiftQueryResponse( + success=True, + data=RedshiftQueryData( + columns=columns, + rows=rows, + row_count=len(rows), + ), + ) + finally: + conn.close() + + except ValueError as e: + return RedshiftQueryResponse(success=False, error=str(e)) + except redshift_connector.Error as e: + logger.exception("Redshift query failed") + return RedshiftQueryResponse(success=False, error=f"Redshift error: {e}") + except Exception as e: + logger.exception("Redshift query failed") + return RedshiftQueryResponse(success=False, error=str(e)) diff --git a/src/tools/database/schemas.py b/src/tools/database/schemas.py new file mode 100644 index 0000000..07ad678 --- /dev/null +++ b/src/tools/database/schemas.py @@ -0,0 +1,241 @@ +"""Pydantic output schemas for PostgreSQL database tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Column Information +# ============================================================================= + + +class ColumnInfo(BaseModel): + """Information about a table column.""" + + name: str = Field(..., description="Column name") + type: str = Field(..., description="Data type") + nullable: bool = Field(..., description="Whether the column allows NULL values") + default: str | None = Field(None, description="Default value expression") + max_length: int | None = Field(None, description="Maximum character length") + precision: int | None = Field(None, description="Numeric precision") + scale: int | None = Field(None, description="Numeric scale") + + +# ============================================================================= +# Tool Data Schemas +# ============================================================================= + + +class ExecuteQuerySelectData(BaseModel): + """Output data for execute_query tool (SELECT queries).""" + + rows: list[dict[str, Any]] = Field(..., description="Query result rows") + row_count: int = Field(..., description="Number of rows returned") + + +class ExecuteQueryModifyData(BaseModel): + """Output data for execute_query tool (INSERT/UPDATE/DELETE queries).""" + + affected_rows: int = Field(..., description="Number of rows affected") + message: str = Field(..., description="Success message") + table_contents: str | None = Field( + default=None, description="Updated table contents as string" + ) + + +class ListTablesData(BaseModel): + """Output data for list_tables tool.""" + + schema_name: str = Field( + ..., serialization_alias="schema", description="Database schema name" + ) + tables: list[str] = Field(..., description="List of table names") + count: int = Field(..., description="Number of tables") + + +class DescribeTableData(BaseModel): + """Output data for describe_table tool.""" + + schema_name: str = Field( + ..., serialization_alias="schema", description="Database schema name" + ) + table: str = Field(..., description="Table name") + columns: list[ColumnInfo] = Field(..., description="Column definitions") + column_count: int = Field(..., description="Number of columns") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ExecuteQueryResponse( + ToolResponse[ExecuteQuerySelectData | ExecuteQueryModifyData] +): + """Response schema for execute_query tool. + + The data field structure depends on query type: + - SELECT/WITH: Contains rows and row_count + - INSERT/UPDATE/DELETE: Contains affected_rows, message, and optionally table_contents + """ + + pass + + +class ListTablesResponse(ToolResponse[ListTablesData]): + """Response schema for list_tables tool.""" + + pass + + +class DescribeTableResponse(ToolResponse[DescribeTableData]): + """Response schema for describe_table tool.""" + + pass + + +# ============================================================================= +# DuckDB Schemas +# ============================================================================= + + +class DuckDbQueryData(BaseModel): + """Output data for duckdb_query tool.""" + + columns: list[str] = Field( + default_factory=list, description="Column names from the result" + ) + rows: list[dict[str, Any]] = Field(..., description="Query result rows") + row_count: int = Field(..., description="Number of rows returned") + + +class DuckDbListTablesData(BaseModel): + """Output data for duckdb_list_tables tool.""" + + tables: list[str] = Field(..., description="List of table names") + count: int = Field(..., description="Number of tables") + database_path: str | None = Field( + None, description="Database file path (None for in-memory)" + ) + + +class DuckDbReadFileData(BaseModel): + """Output data for duckdb_read_file tool.""" + + file_path: str = Field(..., description="Path to the file that was read") + columns: list[str] = Field( + default_factory=list, description="Column names from the file" + ) + rows: list[dict[str, Any]] = Field(..., description="Data rows from the file") + row_count: int = Field(..., description="Number of rows returned") + + +# ============================================================================= +# Neo4j Schemas +# ============================================================================= + + +class Neo4jQueryData(BaseModel): + """Output data for neo4j_query tool.""" + + records: list[dict[str, Any]] = Field(..., description="Query result records") + record_count: int = Field(..., description="Number of records returned") + + +class Neo4jSchemaData(BaseModel): + """Output data for neo4j_get_schema tool.""" + + labels: list[str] = Field(..., description="Node labels in the database") + relationship_types: list[str] = Field( + ..., description="Relationship types in the database" + ) + schema_visualization: list[dict[str, Any]] = Field( + default_factory=list, + description="Schema visualization data from db.schema.visualization()", + ) + + +# ============================================================================= +# SQL (Generic SQLAlchemy) Schemas +# ============================================================================= + + +class SqlQueryData(BaseModel): + """Output data for sql_query tool.""" + + rows: list[dict[str, Any]] = Field( + default_factory=list, description="Query result rows" + ) + row_count: int = Field(..., description="Number of rows returned") + affected_rows: int | None = Field( + None, description="Number of rows affected (for INSERT/UPDATE/DELETE)" + ) + + +# ============================================================================= +# Redshift Schemas +# ============================================================================= + + +class RedshiftQueryData(BaseModel): + """Output data for redshift_query tool.""" + + columns: list[str] = Field( + default_factory=list, description="Column names from the result" + ) + rows: list[dict[str, Any]] = Field( + default_factory=list, description="Query result rows" + ) + row_count: int = Field(..., description="Number of rows returned") + message: str | None = Field( + default=None, description="Status message for non-SELECT queries" + ) + + +# ============================================================================= +# New Response Wrappers +# ============================================================================= + + +class DuckDbQueryResponse(ToolResponse[DuckDbQueryData]): + """Response schema for duckdb_query tool.""" + + pass + + +class DuckDbListTablesResponse(ToolResponse[DuckDbListTablesData]): + """Response schema for duckdb_list_tables tool.""" + + pass + + +class DuckDbReadFileResponse(ToolResponse[DuckDbReadFileData]): + """Response schema for duckdb_read_file tool.""" + + pass + + +class Neo4jQueryResponse(ToolResponse[Neo4jQueryData]): + """Response schema for neo4j_query tool.""" + + pass + + +class Neo4jSchemaResponse(ToolResponse[Neo4jSchemaData]): + """Response schema for neo4j_get_schema tool.""" + + pass + + +class SqlQueryResponse(ToolResponse[SqlQueryData]): + """Response schema for sql_query tool.""" + + pass + + +class RedshiftQueryResponse(ToolResponse[RedshiftQueryData]): + """Response schema for redshift_query tool.""" + + pass diff --git a/src/tools/database/sql.py b/src/tools/database/sql.py new file mode 100644 index 0000000..07cf3b0 --- /dev/null +++ b/src/tools/database/sql.py @@ -0,0 +1,101 @@ +"""Generic SQL database tools using SQLAlchemy.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.humcp.permissions import require_auth +from src.tools.database.schemas import ( + SqlQueryData, + SqlQueryResponse, +) + +try: + from sqlalchemy import create_engine, text + from sqlalchemy.exc import SQLAlchemyError +except ImportError as err: + raise ImportError( + "sqlalchemy is required for SQL tools. Install with: pip install sqlalchemy" + ) from err + +logger = logging.getLogger("humcp.tools.database.sql") + + +@tool() +async def sql_query( + query: str, + connection_string: str | None = None, + params: dict[str, Any] | None = None, +) -> SqlQueryResponse: + """Execute a SQL query against any SQLAlchemy-supported database. + + Supports any database with a SQLAlchemy dialect (PostgreSQL, MySQL, SQLite, + Oracle, MSSQL, etc.). For SELECT queries, returns rows as a list of + dictionaries. For modification queries, returns the number of affected rows. + + Args: + query: The SQL query to execute. + connection_string: SQLAlchemy connection URL (e.g., "sqlite:///mydb.db", + "mysql://user:pass@host/db"). Falls back to SQL_CONNECTION_STRING env var. + params: Optional dictionary of query parameters for parameterized queries. + + Returns: + Query results or affected row count. + """ + try: + await require_auth() + + resolved_connection = connection_string or await resolve_credential( + "SQL_CONNECTION_STRING" + ) + if not resolved_connection: + return SqlQueryResponse( + success=False, + error="No connection string provided. Pass connection_string or set SQL_CONNECTION_STRING env var.", + ) + + logger.info("Executing SQL query: %s", query) + + engine = create_engine(resolved_connection) + try: + with engine.connect() as conn: + result = conn.execute(text(query), params or {}) + + query_upper = query.strip().upper() + is_select = query_upper.startswith("SELECT") or query_upper.startswith( + "WITH" + ) + + if is_select: + rows = [dict(row._mapping) for row in result.fetchall()] + return SqlQueryResponse( + success=True, + data=SqlQueryData( + rows=rows, + row_count=len(rows), + affected_rows=None, + ), + ) + else: + conn.commit() + affected = result.rowcount + return SqlQueryResponse( + success=True, + data=SqlQueryData( + rows=[], + row_count=0, + affected_rows=affected, + ), + ) + finally: + engine.dispose() + + except SQLAlchemyError as e: + logger.exception("SQL query failed") + return SqlQueryResponse(success=False, error=f"Database error: {e}") + except Exception as e: + logger.exception("SQL query failed") + return SqlQueryResponse(success=False, error=str(e)) diff --git a/src/tools/ecommerce/SKILL.md b/src/tools/ecommerce/SKILL.md new file mode 100644 index 0000000..4a2479a --- /dev/null +++ b/src/tools/ecommerce/SKILL.md @@ -0,0 +1,86 @@ +--- +name: ecommerce +description: Ecommerce tools for managing Shopify products and retrieving brand data. Use when the user needs to list, view, or create Shopify products, or look up brand assets like logos and colors. +--- + +# Ecommerce Tools + +Tools for interacting with ecommerce platforms and brand data APIs. + +## Available Tools + +### Shopify (Required: SHOPIFY_STORE_URL, SHOPIFY_ACCESS_TOKEN) + +- `shopify_list_products` - List products from a Shopify store +- `shopify_get_product` - Get a single product by ID +- `shopify_create_product` - Create a new product + +### Brandfetch (Required: BRANDFETCH_API_KEY) + +- `brandfetch_get_brand` - Get brand data (logos, colors, description) by domain + +## Requirements + +Set environment variables: +- `SHOPIFY_STORE_URL`: Your Shopify store URL (e.g., `https://my-store.myshopify.com`) +- `SHOPIFY_ACCESS_TOKEN`: Shopify Admin API access token +- `BRANDFETCH_API_KEY`: Brandfetch API key + +## Examples + +### List products + +```python +result = await shopify_list_products(limit=10) +``` + +### Response format + +```json +{ + "success": true, + "data": { + "products": [ + { + "id": 1234567890, + "title": "Example Product", + "status": "active", + "vendor": "My Store", + "variants": [ + { + "id": 9876543210, + "title": "Default Title", + "price": "29.99", + "inventory_quantity": 100 + } + ] + } + ], + "count": 1 + } +} +``` + +### Create a product + +```python +result = await shopify_create_product( + title="New Widget", + body_html="

A fantastic widget

", + vendor="Widget Co" +) +``` + +### Get brand data + +```python +result = await brandfetch_get_brand(domain="nike.com") +``` + +## When to Use + +- Managing products in a Shopify store +- Listing or searching for products +- Creating new products programmatically +- Looking up brand assets (logos, colors, fonts) +- Getting company brand information for design or marketing diff --git a/src/tools/ecommerce/__init__.py b/src/tools/ecommerce/__init__.py new file mode 100644 index 0000000..58bbb8e --- /dev/null +++ b/src/tools/ecommerce/__init__.py @@ -0,0 +1 @@ +# Ecommerce tools for Shopify and brand data diff --git a/src/tools/ecommerce/brandfetch.py b/src/tools/ecommerce/brandfetch.py new file mode 100644 index 0000000..be381f8 --- /dev/null +++ b/src/tools/ecommerce/brandfetch.py @@ -0,0 +1,125 @@ +"""Brandfetch API tool for retrieving brand data by domain.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.ecommerce.schemas import ( + BrandfetchBrandData, + BrandfetchBrandResponse, + BrandfetchColor, + BrandfetchLogo, +) + +logger = logging.getLogger("humcp.tools.brandfetch") + +BRANDFETCH_API_URL = "https://api.brandfetch.io/v2/brands" + + +@tool() +async def brandfetch_get_brand(domain: str) -> BrandfetchBrandResponse: + """ + Get brand data (logos, colors, description, links) for a domain using the Brandfetch API. + + Retrieves comprehensive brand information including logos in multiple formats, + brand colors, descriptions, social links, and company details. + + Args: + domain: The brand domain to look up (e.g., 'nike.com', 'apple.com'). + Also accepts brand IDs, ISINs, or stock tickers. + + Returns: + Brand data including logos, colors, and company info, or error message. + """ + try: + api_key = await resolve_credential("BRANDFETCH_API_KEY") + if not api_key: + return BrandfetchBrandResponse( + success=False, + error="BRANDFETCH_API_KEY not configured. Contact administrator.", + ) + + if not domain.strip(): + return BrandfetchBrandResponse( + success=False, + error="Domain parameter cannot be empty.", + ) + + logger.info("Fetching brand data for %s", domain) + + url = f"{BRANDFETCH_API_URL}/{domain}" + headers = {"Authorization": f"Bearer {api_key}"} + + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + + logos = [ + BrandfetchLogo( + type=logo.get("type"), + theme=logo.get("theme"), + formats=logo.get("formats", []), + ) + for logo in data.get("logos", []) + ] + + colors = [ + BrandfetchColor( + hex=color.get("hex"), + type=color.get("type"), + brightness=color.get("brightness"), + ) + for color in data.get("colors", []) + ] + + brand_data = BrandfetchBrandData( + name=data.get("name"), + domain=data.get("domain"), + description=data.get("description"), + long_description=data.get("longDescription"), + logos=logos, + colors=colors, + links=data.get("links", []), + company=data.get("company"), + ) + + return BrandfetchBrandResponse( + success=True, + data=brand_data, + ) + except httpx.HTTPStatusError as e: + status = e.response.status_code + if status == 404: + logger.warning("Brand not found for domain %s", domain) + return BrandfetchBrandResponse( + success=False, + error=f"Brand not found for domain: {domain}", + ) + if status == 401: + logger.error("Invalid Brandfetch API key") + return BrandfetchBrandResponse( + success=False, + error="Invalid BRANDFETCH_API_KEY. Check your API key configuration.", + ) + if status == 429: + logger.warning("Brandfetch rate limit exceeded") + return BrandfetchBrandResponse( + success=False, + error="Brandfetch rate limit exceeded. Please try again later.", + ) + logger.exception("HTTP error fetching brand for %s", domain) + return BrandfetchBrandResponse( + success=False, + error=f"Brandfetch API error ({status}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to fetch brand data for %s", domain) + return BrandfetchBrandResponse( + success=False, + error=f"Error fetching brand data for {domain}: {e}", + ) diff --git a/src/tools/ecommerce/schemas.py b/src/tools/ecommerce/schemas.py new file mode 100644 index 0000000..fb28b3a --- /dev/null +++ b/src/tools/ecommerce/schemas.py @@ -0,0 +1,192 @@ +"""Pydantic output schemas for ecommerce tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Shopify Schemas +# ============================================================================= + + +class ShopifyVariant(BaseModel): + """A single product variant from Shopify.""" + + id: int = Field(..., description="Variant ID") + title: str | None = Field(None, description="Variant title") + sku: str | None = Field(None, description="Variant SKU") + price: str | None = Field(None, description="Variant price") + inventory_quantity: int | None = Field(None, description="Inventory quantity") + + +class ShopifyProduct(BaseModel): + """A single product from Shopify.""" + + id: int = Field(..., description="Product ID") + title: str = Field(..., description="Product title") + body_html: str | None = Field(None, description="Product description HTML") + vendor: str | None = Field(None, description="Product vendor") + product_type: str | None = Field(None, description="Product type") + status: str | None = Field( + None, description="Product status (active/draft/archived)" + ) + created_at: str | None = Field(None, description="Creation timestamp") + updated_at: str | None = Field(None, description="Last update timestamp") + variants: list[ShopifyVariant] = Field( + default_factory=list, description="Product variants" + ) + + +class ShopifyListProductsData(BaseModel): + """Output data for shopify_list_products tool.""" + + products: list[ShopifyProduct] = Field( + default_factory=list, description="List of products" + ) + count: int = Field(0, description="Number of products returned") + + +class ShopifyGetProductData(BaseModel): + """Output data for shopify_get_product tool.""" + + product: ShopifyProduct = Field(..., description="Product details") + + +class ShopifyCreateProductData(BaseModel): + """Output data for shopify_create_product tool.""" + + product: ShopifyProduct = Field(..., description="Created product details") + + +class ShopifyOrder(BaseModel): + """A single order from Shopify.""" + + id: int = Field(..., description="Order ID") + name: str | None = Field(None, description="Order name (e.g., '#1001')") + email: str | None = Field(None, description="Customer email") + financial_status: str | None = Field( + None, description="Payment status (paid/pending/refunded)" + ) + fulfillment_status: str | None = Field( + None, description="Fulfillment status (fulfilled/unfulfilled/partial)" + ) + total_price: str | None = Field(None, description="Total order price") + currency: str | None = Field(None, description="Currency code") + created_at: str | None = Field(None, description="Order creation timestamp") + updated_at: str | None = Field(None, description="Last update timestamp") + line_items_count: int = Field(0, description="Number of line items") + + +class ShopifyListOrdersData(BaseModel): + """Output data for shopify_list_orders tool.""" + + orders: list[ShopifyOrder] = Field( + default_factory=list, description="List of orders" + ) + count: int = Field(0, description="Number of orders returned") + + +class ShopifyCustomer(BaseModel): + """A single customer from Shopify.""" + + id: int = Field(..., description="Customer ID") + email: str | None = Field(None, description="Customer email address") + first_name: str | None = Field(None, description="Customer first name") + last_name: str | None = Field(None, description="Customer last name") + orders_count: int | None = Field(None, description="Number of orders placed") + total_spent: str | None = Field(None, description="Total amount spent") + state: str | None = Field( + None, description="Customer account state (enabled/disabled/invited)" + ) + created_at: str | None = Field(None, description="Account creation timestamp") + updated_at: str | None = Field(None, description="Last update timestamp") + + +class ShopifyGetCustomersData(BaseModel): + """Output data for shopify_get_customers tool.""" + + customers: list[ShopifyCustomer] = Field( + default_factory=list, description="List of customers" + ) + count: int = Field(0, description="Number of customers returned") + + +# ============================================================================= +# Brandfetch Schemas +# ============================================================================= + + +class BrandfetchLogo(BaseModel): + """A brand logo from Brandfetch.""" + + type: str | None = Field(None, description="Logo type (e.g., 'logo', 'icon')") + theme: str | None = Field(None, description="Logo theme (e.g., 'light', 'dark')") + formats: list[dict] = Field( + default_factory=list, description="Available logo formats with URLs" + ) + + +class BrandfetchColor(BaseModel): + """A brand color from Brandfetch.""" + + hex: str | None = Field(None, description="Hex color code") + type: str | None = Field(None, description="Color type (e.g., 'accent', 'brand')") + brightness: int | None = Field(None, description="Color brightness value") + + +class BrandfetchBrandData(BaseModel): + """Output data for brandfetch_get_brand tool.""" + + name: str | None = Field(None, description="Brand name") + domain: str | None = Field(None, description="Brand domain") + description: str | None = Field(None, description="Brand description") + long_description: str | None = Field(None, description="Detailed brand description") + logos: list[BrandfetchLogo] = Field(default_factory=list, description="Brand logos") + colors: list[BrandfetchColor] = Field( + default_factory=list, description="Brand colors" + ) + links: list[dict] = Field( + default_factory=list, description="Brand social and web links" + ) + company: dict | None = Field(None, description="Company information") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ShopifyListProductsResponse(ToolResponse[ShopifyListProductsData]): + """Response schema for shopify_list_products tool.""" + + pass + + +class ShopifyGetProductResponse(ToolResponse[ShopifyGetProductData]): + """Response schema for shopify_get_product tool.""" + + pass + + +class ShopifyCreateProductResponse(ToolResponse[ShopifyCreateProductData]): + """Response schema for shopify_create_product tool.""" + + pass + + +class ShopifyListOrdersResponse(ToolResponse[ShopifyListOrdersData]): + """Response schema for shopify_list_orders tool.""" + + pass + + +class ShopifyGetCustomersResponse(ToolResponse[ShopifyGetCustomersData]): + """Response schema for shopify_get_customers tool.""" + + pass + + +class BrandfetchBrandResponse(ToolResponse[BrandfetchBrandData]): + """Response schema for brandfetch_get_brand tool.""" + + pass diff --git a/src/tools/ecommerce/shopify.py b/src/tools/ecommerce/shopify.py new file mode 100644 index 0000000..17f0e06 --- /dev/null +++ b/src/tools/ecommerce/shopify.py @@ -0,0 +1,443 @@ +"""Shopify Admin REST API tools for managing products.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.ecommerce.schemas import ( + ShopifyCreateProductData, + ShopifyCreateProductResponse, + ShopifyCustomer, + ShopifyGetCustomersData, + ShopifyGetCustomersResponse, + ShopifyGetProductData, + ShopifyGetProductResponse, + ShopifyListOrdersData, + ShopifyListOrdersResponse, + ShopifyListProductsData, + ShopifyListProductsResponse, + ShopifyOrder, + ShopifyProduct, + ShopifyVariant, +) + +logger = logging.getLogger("humcp.tools.shopify") + +API_VERSION = "2024-01" + + +def _get_shopify_config( + store_url: str | None, access_token: str | None +) -> tuple[str, str] | None: + """Validate Shopify store URL and access token. + + Returns: + A tuple of (store_url, access_token) or None if not configured. + """ + if not store_url or not access_token: + return None + return store_url.rstrip("/"), access_token + + +def _parse_product(product: dict) -> ShopifyProduct: + """Parse a Shopify product dict into a ShopifyProduct model. + + Args: + product: Raw product dictionary from Shopify REST API. + + Returns: + A ShopifyProduct instance. + """ + variants = [ + ShopifyVariant( + id=v.get("id", 0), + title=v.get("title"), + sku=v.get("sku"), + price=v.get("price"), + inventory_quantity=v.get("inventory_quantity"), + ) + for v in product.get("variants", []) + ] + + return ShopifyProduct( + id=product.get("id", 0), + title=product.get("title", ""), + body_html=product.get("body_html"), + vendor=product.get("vendor"), + product_type=product.get("product_type"), + status=product.get("status"), + created_at=product.get("created_at"), + updated_at=product.get("updated_at"), + variants=variants, + ) + + +@tool() +async def shopify_list_products(limit: int = 50) -> ShopifyListProductsResponse: + """ + List products from a Shopify store using the Admin REST API. + + Args: + limit: Maximum number of products to return (1-250). Defaults to 50. + + Returns: + List of products with their details and variants, or error message. + """ + try: + store_url_val = await resolve_credential("SHOPIFY_STORE_URL") + access_token_val = await resolve_credential("SHOPIFY_ACCESS_TOKEN") + config = _get_shopify_config(store_url_val, access_token_val) + if not config: + return ShopifyListProductsResponse( + success=False, + error="Shopify not configured. Set SHOPIFY_STORE_URL and SHOPIFY_ACCESS_TOKEN.", + ) + + store_url, access_token = config + clamped_limit = max(1, min(limit, 250)) + + logger.info("Listing products limit=%d", clamped_limit) + + url = f"{store_url}/admin/api/{API_VERSION}/products.json" + headers = {"X-Shopify-Access-Token": access_token} + params = {"limit": clamped_limit} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + + raw_products = data.get("products", []) + products = [_parse_product(p) for p in raw_products] + + return ShopifyListProductsResponse( + success=True, + data=ShopifyListProductsData( + products=products, + count=len(products), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error listing Shopify products") + return ShopifyListProductsResponse( + success=False, + error=f"Shopify API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list Shopify products") + return ShopifyListProductsResponse( + success=False, + error=f"Error listing Shopify products: {e}", + ) + + +@tool() +async def shopify_get_product(product_id: str) -> ShopifyGetProductResponse: + """ + Get a single product by its ID from a Shopify store. + + Args: + product_id: The numeric Shopify product ID (e.g., '1234567890'). + + Returns: + Product details with variants, or error message. + """ + try: + store_url_val = await resolve_credential("SHOPIFY_STORE_URL") + access_token_val = await resolve_credential("SHOPIFY_ACCESS_TOKEN") + config = _get_shopify_config(store_url_val, access_token_val) + if not config: + return ShopifyGetProductResponse( + success=False, + error="Shopify not configured. Set SHOPIFY_STORE_URL and SHOPIFY_ACCESS_TOKEN.", + ) + + store_url, access_token = config + + logger.info("Fetching product %s", product_id) + + url = f"{store_url}/admin/api/{API_VERSION}/products/{product_id}.json" + headers = {"X-Shopify-Access-Token": access_token} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + + raw_product = data.get("product") + if not raw_product: + return ShopifyGetProductResponse( + success=False, + error=f"Product not found: {product_id}", + ) + + product = _parse_product(raw_product) + + return ShopifyGetProductResponse( + success=True, + data=ShopifyGetProductData(product=product), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error fetching Shopify product %s", product_id) + return ShopifyGetProductResponse( + success=False, + error=f"Shopify API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to fetch Shopify product %s", product_id) + return ShopifyGetProductResponse( + success=False, + error=f"Error fetching Shopify product {product_id}: {e}", + ) + + +@tool() +async def shopify_create_product( + title: str, + body_html: str = "", + vendor: str = "", +) -> ShopifyCreateProductResponse: + """ + Create a new product in a Shopify store using the Admin REST API. + + Args: + title: The product title (required). + body_html: Product description in HTML format. Defaults to empty string. + vendor: Product vendor name. Defaults to empty string. + + Returns: + Created product details, or error message. + """ + try: + store_url_val = await resolve_credential("SHOPIFY_STORE_URL") + access_token_val = await resolve_credential("SHOPIFY_ACCESS_TOKEN") + config = _get_shopify_config(store_url_val, access_token_val) + if not config: + return ShopifyCreateProductResponse( + success=False, + error="Shopify not configured. Set SHOPIFY_STORE_URL and SHOPIFY_ACCESS_TOKEN.", + ) + + store_url, access_token = config + + if not title.strip(): + return ShopifyCreateProductResponse( + success=False, + error="Product title cannot be empty.", + ) + + logger.info("Creating product title=%s vendor=%s", title, vendor) + + url = f"{store_url}/admin/api/{API_VERSION}/products.json" + headers = { + "X-Shopify-Access-Token": access_token, + "Content-Type": "application/json", + } + payload = { + "product": { + "title": title, + "body_html": body_html, + "vendor": vendor, + } + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + raw_product = data.get("product") + if not raw_product: + return ShopifyCreateProductResponse( + success=False, + error="Unexpected response: product not in response body.", + ) + + product = _parse_product(raw_product) + + return ShopifyCreateProductResponse( + success=True, + data=ShopifyCreateProductData(product=product), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error creating Shopify product") + return ShopifyCreateProductResponse( + success=False, + error=f"Shopify API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to create Shopify product") + return ShopifyCreateProductResponse( + success=False, + error=f"Error creating Shopify product: {e}", + ) + + +@tool() +async def shopify_list_orders( + status: str = "any", + limit: int = 10, +) -> ShopifyListOrdersResponse: + """ + List orders from a Shopify store using the Admin REST API. + + Retrieves orders filtered by status. Each order includes its name, customer + email, financial status, fulfillment status, total price, and line item count. + + Args: + status: Order status filter. Valid values: 'open', 'closed', 'cancelled', + 'any'. Defaults to 'any' (returns all orders regardless of status). + limit: Maximum number of orders to return (1-250). Defaults to 10. + + Returns: + List of orders with their details, or error message. + """ + try: + store_url_val = await resolve_credential("SHOPIFY_STORE_URL") + access_token_val = await resolve_credential("SHOPIFY_ACCESS_TOKEN") + config = _get_shopify_config(store_url_val, access_token_val) + if not config: + return ShopifyListOrdersResponse( + success=False, + error="Shopify not configured. Set SHOPIFY_STORE_URL and SHOPIFY_ACCESS_TOKEN.", + ) + + store_url, access_token = config + + valid_statuses = {"open", "closed", "cancelled", "any"} + if status not in valid_statuses: + return ShopifyListOrdersResponse( + success=False, + error=f"Invalid status '{status}'. Valid statuses: {', '.join(sorted(valid_statuses))}", + ) + + clamped_limit = max(1, min(limit, 250)) + + logger.info("Listing orders status=%s limit=%d", status, clamped_limit) + + url = f"{store_url}/admin/api/{API_VERSION}/orders.json" + headers = {"X-Shopify-Access-Token": access_token} + params: dict = {"limit": clamped_limit, "status": status} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + + raw_orders = data.get("orders", []) + orders = [ + ShopifyOrder( + id=o.get("id", 0), + name=o.get("name"), + email=o.get("email"), + financial_status=o.get("financial_status"), + fulfillment_status=o.get("fulfillment_status"), + total_price=o.get("total_price"), + currency=o.get("currency"), + created_at=o.get("created_at"), + updated_at=o.get("updated_at"), + line_items_count=len(o.get("line_items", [])), + ) + for o in raw_orders + ] + + return ShopifyListOrdersResponse( + success=True, + data=ShopifyListOrdersData( + orders=orders, + count=len(orders), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error listing Shopify orders") + return ShopifyListOrdersResponse( + success=False, + error=f"Shopify API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list Shopify orders") + return ShopifyListOrdersResponse( + success=False, + error=f"Error listing Shopify orders: {e}", + ) + + +@tool() +async def shopify_get_customers( + limit: int = 10, +) -> ShopifyGetCustomersResponse: + """ + List customers from a Shopify store using the Admin REST API. + + Retrieves customer accounts with their contact information, order history + summary, and account state. Useful for customer analytics and CRM workflows. + + Args: + limit: Maximum number of customers to return (1-250). Defaults to 10. + + Returns: + List of customers with their details, or error message. + """ + try: + store_url_val = await resolve_credential("SHOPIFY_STORE_URL") + access_token_val = await resolve_credential("SHOPIFY_ACCESS_TOKEN") + config = _get_shopify_config(store_url_val, access_token_val) + if not config: + return ShopifyGetCustomersResponse( + success=False, + error="Shopify not configured. Set SHOPIFY_STORE_URL and SHOPIFY_ACCESS_TOKEN.", + ) + + store_url, access_token = config + clamped_limit = max(1, min(limit, 250)) + + logger.info("Listing customers limit=%d", clamped_limit) + + url = f"{store_url}/admin/api/{API_VERSION}/customers.json" + headers = {"X-Shopify-Access-Token": access_token} + params = {"limit": clamped_limit} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + + raw_customers = data.get("customers", []) + customers = [ + ShopifyCustomer( + id=c.get("id", 0), + email=c.get("email"), + first_name=c.get("first_name"), + last_name=c.get("last_name"), + orders_count=c.get("orders_count"), + total_spent=c.get("total_spent"), + state=c.get("state"), + created_at=c.get("created_at"), + updated_at=c.get("updated_at"), + ) + for c in raw_customers + ] + + return ShopifyGetCustomersResponse( + success=True, + data=ShopifyGetCustomersData( + customers=customers, + count=len(customers), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error listing Shopify customers") + return ShopifyGetCustomersResponse( + success=False, + error=f"Shopify API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to list Shopify customers") + return ShopifyGetCustomersResponse( + success=False, + error=f"Error listing Shopify customers: {e}", + ) diff --git a/src/tools/files/markdown_table.py b/src/tools/files/markdown_table.py new file mode 100644 index 0000000..58e381c --- /dev/null +++ b/src/tools/files/markdown_table.py @@ -0,0 +1,180 @@ +"""Markdown table extraction tool.""" + +from __future__ import annotations + +import csv +import io +import logging +import re + +from src.humcp.decorator import tool +from src.tools.files.schemas import ( + ExtractedTable, + MarkdownExtractTablesData, + MarkdownExtractTablesResponse, +) + +logger = logging.getLogger("humcp.tools.files.markdown_table") + + +def _parse_markdown_table(table_text: str) -> list[list[str]]: + """Parse a markdown table into rows of cells. + + Args: + table_text: Markdown table text. + + Returns: + List of rows, each row is a list of cell values. + """ + lines = table_text.strip().split("\n") + rows: list[list[str]] = [] + + for line in lines: + line = line.strip() + if not line: + continue + + # Skip separator lines (e.g., |---|---|) + if re.match(r"^\|[\s\-:|\+]+\|$", line): + continue + + # Parse cells from pipe-separated line + if line.startswith("|") and line.endswith("|"): + # Remove leading/trailing pipes and split + cells = line[1:-1].split("|") + cells = [cell.strip() for cell in cells] + if cells: + rows.append(cells) + + return rows + + +def _extract_tables_from_markdown(content: str) -> list[list[list[str]]]: + """Extract all tables from markdown content. + + Args: + content: Full markdown content. + + Returns: + List of tables, each table is a list of rows. + """ + tables: list[list[list[str]]] = [] + + # Pattern to match markdown tables + # A table starts with a line containing |, followed by a separator line with - + table_pattern = re.compile( + r"(\|[^\n]+\|\n\|[\s\-:|\+]+\|\n(?:\|[^\n]+\|\n?)*)", + re.MULTILINE, + ) + + matches = table_pattern.findall(content) + + for match in matches: + parsed = _parse_markdown_table(match) + if parsed: + tables.append(parsed) + + return tables + + +def _table_to_csv(table: list[list[str]]) -> str: + """Convert a parsed table to CSV format. + + Args: + table: List of rows, each row is a list of cell values. + + Returns: + CSV formatted string. + """ + output = io.StringIO(newline="") + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL, lineterminator="\n") + + for row in table: + writer.writerow(row) + + return output.getvalue() + + +@tool() +async def markdown_extract_tables( + markdown_content: str, + table_index: int | None = None, +) -> MarkdownExtractTablesResponse: + """Extract tables from markdown content and convert to CSV format. + + Parses markdown tables (pipe-separated format) and outputs CSV strings. + + Args: + markdown_content: Markdown content containing table(s). + table_index: Optional index of specific table to extract (0-based). + If not provided, extracts all tables. + + Returns: + Success status with CSV data for extracted tables. + + Example: + markdown = ''' + | Name | Age | + |------|-----| + | Alice | 30 | + | Bob | 25 | + ''' + # Extract all tables + result = await markdown_extract_tables(markdown_content=markdown) + + # Extract only the first table + result = await markdown_extract_tables(markdown_content=markdown, table_index=0) + """ + try: + tables = _extract_tables_from_markdown(markdown_content) + + if not tables: + return MarkdownExtractTablesResponse( + success=True, + data=MarkdownExtractTablesData( + tables=[], + count=0, + message="No tables found in content", + ), + ) + + # Extract specific table or all tables + if table_index is not None: + if table_index < 0 or table_index >= len(tables): + return MarkdownExtractTablesResponse( + success=False, + error=f"Table index {table_index} out of range. " + f"Content contains {len(tables)} table(s).", + ) + selected_tables = [tables[table_index]] + indices = [table_index] + else: + selected_tables = tables + indices = list(range(len(tables))) + + # Convert to CSV + results = [] + for idx, table in zip(indices, selected_tables, strict=False): + csv_data = _table_to_csv(table) + results.append( + ExtractedTable( + index=idx, + rows=len(table), + columns=len(table[0]) if table else 0, + csv=csv_data, + ) + ) + + logger.info("Extracted %d table(s) from markdown content", len(results)) + + return MarkdownExtractTablesResponse( + success=True, + data=MarkdownExtractTablesData( + tables=results, + count=len(results), + ), + ) + + except Exception as e: + logger.exception("Failed to extract tables from markdown") + return MarkdownExtractTablesResponse(success=False, error=str(e)) diff --git a/src/tools/files/schemas.py b/src/tools/files/schemas.py new file mode 100644 index 0000000..9b53736 --- /dev/null +++ b/src/tools/files/schemas.py @@ -0,0 +1,51 @@ +"""Pydantic output schemas for file conversion tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# PDF to Markdown Schemas +# ============================================================================= + + +class ConvertToMarkdownData(BaseModel): + """Output data for convert_to_markdown tool.""" + + markdown: str = Field(..., description="The converted markdown content") + + +class ConvertToMarkdownResponse(ToolResponse[ConvertToMarkdownData]): + """Response schema for convert_to_markdown tool.""" + + pass + + +# ============================================================================= +# Markdown Table Extraction Schemas +# ============================================================================= + + +class ExtractedTable(BaseModel): + """Information about a single extracted table.""" + + index: int = Field(..., description="Table index in the document (0-based)") + rows: int = Field(..., description="Number of rows in the table") + columns: int = Field(..., description="Number of columns in the table") + csv: str = Field(..., description="Table content as CSV string") + + +class MarkdownExtractTablesData(BaseModel): + """Output data for markdown_extract_tables tool.""" + + tables: list[ExtractedTable] = Field(..., description="List of extracted tables") + count: int = Field(..., description="Number of tables extracted") + message: str | None = Field( + default=None, description="Additional message (e.g., no tables found)" + ) + + +class MarkdownExtractTablesResponse(ToolResponse[MarkdownExtractTablesData]): + """Response schema for markdown_extract_tables tool.""" + + pass diff --git a/src/tools/finance/SKILL.md b/src/tools/finance/SKILL.md new file mode 100644 index 0000000..afc73cd --- /dev/null +++ b/src/tools/finance/SKILL.md @@ -0,0 +1,85 @@ +--- +name: finance +description: Financial data tools for stock prices, company information, financial statements, and blockchain queries. Use when the user needs stock market data, company fundamentals, historical prices, or EVM blockchain information. +--- + +# Finance Tools + +Tools for accessing financial market data, company information, and blockchain networks. + +## Available Tools + +### YFinance (No API key required) + +- `yfinance_get_stock_price` - Get current stock price +- `yfinance_get_stock_info` - Get detailed company information +- `yfinance_get_historical_data` - Get historical price data + +### OpenBB (Optional: OPENBB_TOKEN) + +- `openbb_get_stock_data` - Get stock quotes (supports multiple symbols) +- `openbb_search_stocks` - Search for stocks by company name + +### Financial Datasets (Required: FINANCIAL_DATASETS_API_KEY) + +- `financial_datasets_get_financials` - Get income statements +- `financial_datasets_get_prices` - Get stock price data + +### EVM Blockchain (Optional: EVM_RPC_URL) + +- `evm_get_balance` - Get wallet balance on EVM chains +- `evm_get_transaction` - Get transaction details by hash + +## Requirements + +Set environment variables as needed: +- `OPENBB_TOKEN`: OpenBB Personal Access Token (optional) +- `FINANCIAL_DATASETS_API_KEY`: Financial Datasets API key +- `EVM_RPC_URL`: Custom EVM RPC endpoint (defaults provided for major chains) + +## Examples + +### Get a stock price + +```python +result = await yfinance_get_stock_price(symbol="AAPL") +``` + +### Response format + +```json +{ + "success": true, + "data": { + "symbol": "AAPL", + "price": 178.72, + "currency": "USD" + } +} +``` + +### Get historical data + +```python +result = await yfinance_get_historical_data( + symbol="MSFT", + period="3mo" +) +``` + +### Check blockchain balance + +```python +result = await evm_get_balance( + address="0x742d35Cc6634C0532925a3b844Bc9e7595f2bD1e", + chain="ethereum" +) +``` + +## When to Use + +- Looking up current or historical stock prices +- Researching company fundamentals and financials +- Searching for stock ticker symbols +- Checking EVM wallet balances or transaction details +- Getting income statements or financial metrics diff --git a/src/tools/finance/__init__.py b/src/tools/finance/__init__.py new file mode 100644 index 0000000..82d4b53 --- /dev/null +++ b/src/tools/finance/__init__.py @@ -0,0 +1 @@ +# Finance tools for stock data, blockchain, and financial datasets diff --git a/src/tools/finance/evm.py b/src/tools/finance/evm.py new file mode 100644 index 0000000..be0fc20 --- /dev/null +++ b/src/tools/finance/evm.py @@ -0,0 +1,362 @@ +"""EVM blockchain tools for querying balances and transactions via JSON-RPC.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.finance.schemas import ( + EvmBalanceData, + EvmBalanceResponse, + EvmBlockData, + EvmBlockResponse, + EvmGasPriceData, + EvmGasPriceResponse, + EvmTransactionData, + EvmTransactionResponse, +) + +logger = logging.getLogger("humcp.tools.evm") + +# Default chain RPC URLs when no EVM_RPC_URL is set +DEFAULT_RPC_URLS: dict[str, str] = { + "ethereum": "https://eth.llamarpc.com", + "polygon": "https://polygon-rpc.com", + "arbitrum": "https://arb1.arbitrum.io/rpc", + "optimism": "https://mainnet.optimism.io", + "base": "https://mainnet.base.org", +} + + +def _get_rpc_url(chain: str, env_url: str | None = None) -> str | None: + """Resolve the RPC URL from a provided value or defaults. + + Args: + chain: Chain identifier (e.g., 'ethereum', 'polygon'). + env_url: Optional RPC URL from credential resolution. + + Returns: + The RPC URL string or None if not configured. + """ + if env_url: + return env_url + return DEFAULT_RPC_URLS.get(chain.lower()) + + +async def _rpc_call(rpc_url: str, method: str, params: list) -> dict: + """Make a JSON-RPC call to an EVM node. + + Args: + rpc_url: The RPC endpoint URL. + method: The JSON-RPC method name. + params: The method parameters. + + Returns: + The JSON-RPC result. + """ + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(rpc_url, json=payload) + response.raise_for_status() + data = response.json() + + if "error" in data: + raise ValueError(f"RPC error: {data['error'].get('message', data['error'])}") + + return data + + +@tool() +async def evm_get_balance( + address: str, + chain: str = "ethereum", +) -> EvmBalanceResponse: + """ + Get the native token balance of an EVM wallet address. + + Queries the balance using JSON-RPC eth_getBalance. Supports Ethereum, + Polygon, Arbitrum, Optimism, and Base chains by default. + + Args: + address: The wallet address (0x-prefixed hex string). + chain: Blockchain to query. Defaults to 'ethereum'. Supported chains: + ethereum, polygon, arbitrum, optimism, base. Can also use a + custom chain if EVM_RPC_URL is set. + + Returns: + Wallet balance in wei and ETH (or native token), or error message. + """ + try: + env_url = await resolve_credential("EVM_RPC_URL") + rpc_url = _get_rpc_url(chain, env_url) + if not rpc_url: + return EvmBalanceResponse( + success=False, + error=f"No RPC URL configured for chain '{chain}'. " + f"Set EVM_RPC_URL or use a supported chain: {', '.join(DEFAULT_RPC_URLS.keys())}", + ) + + if not address.startswith("0x") or len(address) != 42: + return EvmBalanceResponse( + success=False, + error=f"Invalid address format: '{address}'. Must be a 0x-prefixed 40-char hex string.", + ) + + logger.info("Fetching balance for %s on %s", address, chain) + result = await _rpc_call(rpc_url, "eth_getBalance", [address, "latest"]) + + balance_hex = result.get("result", "0x0") + balance_wei = int(balance_hex, 16) + balance_eth = balance_wei / 1e18 + + return EvmBalanceResponse( + success=True, + data=EvmBalanceData( + address=address, + balance_wei=str(balance_wei), + balance_eth=f"{balance_eth:.18f}", + chain=chain, + ), + ) + except Exception as e: + logger.exception("Failed to fetch balance for %s on %s", address, chain) + return EvmBalanceResponse( + success=False, + error=f"Error fetching balance for {address} on {chain}: {e}", + ) + + +@tool() +async def evm_get_transaction( + tx_hash: str, + chain: str = "ethereum", +) -> EvmTransactionResponse: + """ + Get details of a transaction by its hash from an EVM-compatible blockchain. + + Retrieves the transaction data and receipt (including status and gas used) + using JSON-RPC eth_getTransactionByHash and eth_getTransactionReceipt. + + Args: + tx_hash: The transaction hash (0x-prefixed hex string). + chain: Blockchain to query. Defaults to 'ethereum'. Supported chains: + ethereum, polygon, arbitrum, optimism, base. + + Returns: + Transaction details or error message. + """ + try: + env_url = await resolve_credential("EVM_RPC_URL") + rpc_url = _get_rpc_url(chain, env_url) + if not rpc_url: + return EvmTransactionResponse( + success=False, + error=f"No RPC URL configured for chain '{chain}'. " + f"Set EVM_RPC_URL or use a supported chain: {', '.join(DEFAULT_RPC_URLS.keys())}", + ) + + if not tx_hash.startswith("0x"): + return EvmTransactionResponse( + success=False, + error=f"Invalid transaction hash format: '{tx_hash}'. Must be 0x-prefixed.", + ) + + logger.info("Fetching transaction %s on %s", tx_hash, chain) + + tx_result = await _rpc_call(rpc_url, "eth_getTransactionByHash", [tx_hash]) + tx_data = tx_result.get("result") + + if not tx_data: + return EvmTransactionResponse( + success=False, + error=f"Transaction not found: {tx_hash}", + ) + + # Fetch the receipt for status and gas used + receipt_result = await _rpc_call( + rpc_url, "eth_getTransactionReceipt", [tx_hash] + ) + receipt = receipt_result.get("result", {}) + + value_hex = tx_data.get("value", "0x0") + value_wei = str(int(value_hex, 16)) + gas_used = ( + int(receipt.get("gasUsed", "0x0"), 16) if receipt.get("gasUsed") else None + ) + block_number = ( + int(tx_data.get("blockNumber", "0x0"), 16) + if tx_data.get("blockNumber") + else None + ) + status = ( + int(receipt.get("status", "0x0"), 16) if receipt.get("status") else None + ) + + return EvmTransactionResponse( + success=True, + data=EvmTransactionData( + tx_hash=tx_hash, + from_address=tx_data.get("from"), + to_address=tx_data.get("to"), + value_wei=value_wei, + gas_used=gas_used, + block_number=block_number, + status=status, + chain=chain, + ), + ) + except Exception as e: + logger.exception("Failed to fetch transaction %s on %s", tx_hash, chain) + return EvmTransactionResponse( + success=False, + error=f"Error fetching transaction {tx_hash} on {chain}: {e}", + ) + + +@tool() +async def evm_get_block( + block_number: str = "latest", + chain: str = "ethereum", +) -> EvmBlockResponse: + """ + Get block information from an EVM-compatible blockchain by block number. + + Retrieves block metadata including hash, timestamp, gas usage, transaction + count, and miner/validator address using JSON-RPC eth_getBlockByNumber. + Supports Ethereum, Polygon, Arbitrum, Optimism, and Base chains by default. + + Args: + block_number: Block number as a decimal string (e.g., '12345678') or + 'latest' for the most recent block. Defaults to 'latest'. + chain: Blockchain to query. Defaults to 'ethereum'. Supported chains: + ethereum, polygon, arbitrum, optimism, base. Can also use a + custom chain if EVM_RPC_URL is set. + + Returns: + Block information including hash, timestamp, gas, and transaction count, + or error message. + """ + try: + env_url = await resolve_credential("EVM_RPC_URL") + rpc_url = _get_rpc_url(chain, env_url) + if not rpc_url: + return EvmBlockResponse( + success=False, + error=f"No RPC URL configured for chain '{chain}'. " + f"Set EVM_RPC_URL or use a supported chain: {', '.join(DEFAULT_RPC_URLS.keys())}", + ) + + if block_number == "latest": + block_param = "latest" + else: + try: + block_param = hex(int(block_number)) + except ValueError: + return EvmBlockResponse( + success=False, + error=f"Invalid block number: '{block_number}'. Must be a decimal number or 'latest'.", + ) + + logger.info("Fetching block %s on %s", block_number, chain) + result = await _rpc_call(rpc_url, "eth_getBlockByNumber", [block_param, False]) + + block = result.get("result") + if not block: + return EvmBlockResponse( + success=False, + error=f"Block not found: {block_number}", + ) + + parsed_block_number = int(block.get("number", "0x0"), 16) + timestamp = ( + int(block.get("timestamp", "0x0"), 16) if block.get("timestamp") else None + ) + gas_used = ( + int(block.get("gasUsed", "0x0"), 16) if block.get("gasUsed") else None + ) + gas_limit = ( + int(block.get("gasLimit", "0x0"), 16) if block.get("gasLimit") else None + ) + transactions = block.get("transactions", []) + + return EvmBlockResponse( + success=True, + data=EvmBlockData( + block_number=parsed_block_number, + block_hash=block.get("hash"), + parent_hash=block.get("parentHash"), + timestamp=timestamp, + gas_used=gas_used, + gas_limit=gas_limit, + transaction_count=len(transactions), + miner=block.get("miner"), + chain=chain, + ), + ) + except Exception as e: + logger.exception("Failed to fetch block %s on %s", block_number, chain) + return EvmBlockResponse( + success=False, + error=f"Error fetching block {block_number} on {chain}: {e}", + ) + + +@tool() +async def evm_get_gas_price( + chain: str = "ethereum", +) -> EvmGasPriceResponse: + """ + Get the current gas price from an EVM-compatible blockchain. + + Returns the gas price in both wei and Gwei using JSON-RPC eth_gasPrice. + Useful for estimating transaction costs before submitting transactions. + Supports Ethereum, Polygon, Arbitrum, Optimism, and Base chains by default. + + Args: + chain: Blockchain to query. Defaults to 'ethereum'. Supported chains: + ethereum, polygon, arbitrum, optimism, base. Can also use a + custom chain if EVM_RPC_URL is set. + + Returns: + Current gas price in wei and Gwei, or error message. + """ + try: + env_url = await resolve_credential("EVM_RPC_URL") + rpc_url = _get_rpc_url(chain, env_url) + if not rpc_url: + return EvmGasPriceResponse( + success=False, + error=f"No RPC URL configured for chain '{chain}'. " + f"Set EVM_RPC_URL or use a supported chain: {', '.join(DEFAULT_RPC_URLS.keys())}", + ) + + logger.info("Fetching gas price on %s", chain) + result = await _rpc_call(rpc_url, "eth_gasPrice", []) + + gas_price_hex = result.get("result", "0x0") + gas_price_wei = int(gas_price_hex, 16) + gas_price_gwei = gas_price_wei / 1e9 + + return EvmGasPriceResponse( + success=True, + data=EvmGasPriceData( + gas_price_wei=str(gas_price_wei), + gas_price_gwei=f"{gas_price_gwei:.9f}", + chain=chain, + ), + ) + except Exception as e: + logger.exception("Failed to fetch gas price on %s", chain) + return EvmGasPriceResponse( + success=False, + error=f"Error fetching gas price on {chain}: {e}", + ) diff --git a/src/tools/finance/financial_datasets.py b/src/tools/finance/financial_datasets.py new file mode 100644 index 0000000..be976fd --- /dev/null +++ b/src/tools/finance/financial_datasets.py @@ -0,0 +1,248 @@ +"""Financial Datasets API tools for fetching financial statements and stock prices.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.finance.schemas import ( + FinancialDatasetsFinancialsData, + FinancialDatasetsFinancialsResponse, + FinancialDatasetsInsiderTradesData, + FinancialDatasetsInsiderTradesResponse, + FinancialDatasetsPricesData, + FinancialDatasetsPricesResponse, + FinancialStatement, + InsiderTrade, + PriceEntry, +) + +logger = logging.getLogger("humcp.tools.financial_datasets") + +BASE_URL = "https://api.financialdatasets.ai" + + +async def _make_request(endpoint: str, params: dict, api_key: str) -> dict: + """Make an authenticated GET request to the Financial Datasets API. + + Args: + endpoint: API endpoint path (e.g., 'financials/income-statements'). + params: Query parameters for the request. + api_key: Financial Datasets API key. + + Returns: + Parsed JSON response as a dictionary. + """ + url = f"{BASE_URL}/{endpoint}" + headers = {"X-API-KEY": api_key} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + +@tool() +async def financial_datasets_get_financials( + ticker: str, + period: str = "annual", +) -> FinancialDatasetsFinancialsResponse: + """ + Get income statements for a given stock ticker from the Financial Datasets API. + + Args: + ticker: Stock ticker symbol (e.g., 'AAPL', 'MSFT'). + period: Reporting period: 'annual', 'quarterly', or 'ttm'. Defaults to 'annual'. + + Returns: + Financial statement data or error message. + """ + try: + api_key = await resolve_credential("FINANCIAL_DATASETS_API_KEY") + if not api_key: + return FinancialDatasetsFinancialsResponse( + success=False, + error="FINANCIAL_DATASETS_API_KEY not configured. Contact administrator.", + ) + + valid_periods = {"annual", "quarterly", "ttm"} + if period not in valid_periods: + return FinancialDatasetsFinancialsResponse( + success=False, + error=f"Invalid period '{period}'. Valid periods: {', '.join(sorted(valid_periods))}", + ) + + logger.info("Fetching financials for %s period=%s", ticker, period) + params = {"ticker": ticker, "period": period, "limit": 10} + result = await _make_request("financials/income-statements", params, api_key) + + raw_statements = result.get("income_statements", []) + statements = [ + FinancialStatement( + ticker=ticker, + period=period, + data=stmt, + ) + for stmt in raw_statements + ] + + return FinancialDatasetsFinancialsResponse( + success=True, + data=FinancialDatasetsFinancialsData( + ticker=ticker, + period=period, + statements=statements, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error fetching financials for %s", ticker) + return FinancialDatasetsFinancialsResponse( + success=False, + error=f"API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to fetch financials for %s", ticker) + return FinancialDatasetsFinancialsResponse( + success=False, + error=f"Error fetching financials for {ticker}: {e}", + ) + + +@tool() +async def financial_datasets_get_prices( + ticker: str, + period: str = "1d", +) -> FinancialDatasetsPricesResponse: + """ + Get stock price data for a given ticker from the Financial Datasets API. + + Args: + ticker: Stock ticker symbol (e.g., 'AAPL', 'MSFT'). + period: Price interval (e.g., '1d', '1h'). Defaults to '1d'. + + Returns: + Stock price data or error message. + """ + try: + api_key = await resolve_credential("FINANCIAL_DATASETS_API_KEY") + if not api_key: + return FinancialDatasetsPricesResponse( + success=False, + error="FINANCIAL_DATASETS_API_KEY not configured. Contact administrator.", + ) + + logger.info("Fetching prices for %s period=%s", ticker, period) + params = {"ticker": ticker, "interval": period, "limit": 100} + result = await _make_request("prices", params, api_key) + + raw_prices = result.get("prices", []) + prices = [ + PriceEntry( + date=p.get("time") or p.get("date"), + open=p.get("open"), + high=p.get("high"), + low=p.get("low"), + close=p.get("close"), + volume=p.get("volume"), + ) + for p in raw_prices + ] + + return FinancialDatasetsPricesResponse( + success=True, + data=FinancialDatasetsPricesData( + ticker=ticker, + prices=prices, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error fetching prices for %s", ticker) + return FinancialDatasetsPricesResponse( + success=False, + error=f"API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to fetch prices for %s", ticker) + return FinancialDatasetsPricesResponse( + success=False, + error=f"Error fetching prices for {ticker}: {e}", + ) + + +@tool() +async def financial_datasets_get_insider_trades( + ticker: str, + limit: int = 50, +) -> FinancialDatasetsInsiderTradesResponse: + """ + Get insider trading data for a given stock ticker from the Financial Datasets API. + + Retrieves SEC-filed insider trades including purchases and sales by company + officers, directors, and significant shareholders. Each trade includes the + insider's name, title, transaction type, share count, price, and filing date. + + Args: + ticker: Stock ticker symbol (e.g., 'AAPL', 'MSFT', 'TSLA'). + limit: Maximum number of insider trade records to return (default 50). + + Returns: + Insider trading data or error message. + """ + try: + api_key = await resolve_credential("FINANCIAL_DATASETS_API_KEY") + if not api_key: + return FinancialDatasetsInsiderTradesResponse( + success=False, + error="FINANCIAL_DATASETS_API_KEY not configured. Contact administrator.", + ) + + if limit < 1: + return FinancialDatasetsInsiderTradesResponse( + success=False, + error="limit must be at least 1", + ) + + logger.info("Fetching insider trades for %s limit=%d", ticker, limit) + params = {"ticker": ticker, "limit": min(limit, 500)} + result = await _make_request("insider-trades", params, api_key) + + raw_trades = result.get("insider_trades", []) + trades = [ + InsiderTrade( + ticker=t.get("ticker"), + company_name=t.get("company_name"), + insider_name=t.get("full_name") or t.get("insider_name"), + insider_title=t.get("title") or t.get("insider_title"), + transaction_type=t.get("transaction_type"), + shares=t.get("shares"), + price_per_share=t.get("price_per_share"), + total_value=t.get("total_value"), + filing_date=t.get("filing_date"), + transaction_date=t.get("transaction_date"), + ) + for t in raw_trades + ] + + return FinancialDatasetsInsiderTradesResponse( + success=True, + data=FinancialDatasetsInsiderTradesData( + ticker=ticker, + trades=trades, + count=len(trades), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("HTTP error fetching insider trades for %s", ticker) + return FinancialDatasetsInsiderTradesResponse( + success=False, + error=f"API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Failed to fetch insider trades for %s", ticker) + return FinancialDatasetsInsiderTradesResponse( + success=False, + error=f"Error fetching insider trades for {ticker}: {e}", + ) diff --git a/src/tools/finance/openbb.py b/src/tools/finance/openbb.py new file mode 100644 index 0000000..7cbcf2d --- /dev/null +++ b/src/tools/finance/openbb.py @@ -0,0 +1,174 @@ +"""OpenBB tools for fetching stock data and searching equities.""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.finance.schemas import ( + OpenBBMarketNewsData, + OpenBBMarketNewsResponse, + OpenBBNewsArticle, + OpenBBSearchData, + OpenBBSearchResponse, + OpenBBSearchResult, + OpenBBStockData, + OpenBBStockDataResponse, + OpenBBStockQuote, +) + +try: + from openbb import obb as openbb_app +except ImportError as err: + raise ImportError( + "openbb is required for OpenBB tools. Install with: pip install openbb" + ) from err + +logger = logging.getLogger("humcp.tools.openbb") + + +def _get_openbb_client(token: str | None = None): + """Return the OpenBB client, authenticating if a PAT is available.""" + if token: + try: + openbb_app.account.login(pat=token) + except Exception as e: + logger.warning("OpenBB PAT login failed: %s", e) + return openbb_app + + +@tool() +async def openbb_get_stock_data(symbol: str) -> OpenBBStockDataResponse: + """ + Get current stock quote data for one or more symbols using OpenBB. + + Supports multiple symbols separated by commas (e.g., 'AAPL,MSFT,GOOGL'). + + Args: + symbol: Stock ticker symbol or comma-separated list of symbols + (e.g., 'AAPL' or 'AAPL,MSFT,GOOGL'). + + Returns: + Stock quote data or error message. + """ + try: + logger.info("Fetching stock data for %s via OpenBB", symbol) + token = await resolve_credential("OPENBB_TOKEN") + obb = _get_openbb_client(token) + result = obb.equity.price.quote(symbol=symbol, provider="yfinance").to_polars() + + quotes = [ + OpenBBStockQuote( + symbol=row.get("symbol"), + last_price=row.get("last_price"), + currency=row.get("currency"), + name=row.get("name"), + high=row.get("high"), + low=row.get("low"), + open=row.get("open"), + close=row.get("close"), + volume=row.get("volume"), + ) + for row in result.to_dicts() + ] + + return OpenBBStockDataResponse( + success=True, + data=OpenBBStockData(quotes=quotes), + ) + except Exception as e: + logger.exception("Failed to fetch stock data for %s", symbol) + return OpenBBStockDataResponse( + success=False, + error=f"Error fetching stock data for {symbol}: {e}", + ) + + +@tool() +async def openbb_search_stocks(query: str) -> OpenBBSearchResponse: + """ + Search for stock ticker symbols by company name using OpenBB. + + Args: + query: Company name or partial name to search for (e.g., 'Apple', 'Tesla'). + + Returns: + List of matching stock symbols and company names, or error message. + """ + try: + logger.info("Searching stocks for query=%s via OpenBB", query) + token = await resolve_credential("OPENBB_TOKEN") + obb = _get_openbb_client(token) + result = obb.equity.search(query).to_polars() + + results = [ + OpenBBSearchResult( + symbol=row.get("symbol"), + name=row.get("name"), + ) + for row in result.to_dicts() + ] + + return OpenBBSearchResponse( + success=True, + data=OpenBBSearchData(query=query, results=results), + ) + except Exception as e: + logger.exception("Failed to search stocks for %s", query) + return OpenBBSearchResponse( + success=False, + error=f"Error searching stocks for '{query}': {e}", + ) + + +@tool() +async def openbb_get_market_news( + limit: int = 20, +) -> OpenBBMarketNewsResponse: + """ + Get the latest market news headlines using OpenBB. + + Fetches recent financial and market news articles from available providers. + Each article includes the headline, publication date, summary text, source + URL, related ticker symbols, and publisher name. + + Args: + limit: Maximum number of news articles to return (1-100). Defaults to 20. + + Returns: + List of market news articles, or error message. + """ + try: + clamped_limit = max(1, min(limit, 100)) + + logger.info("Fetching market news limit=%d via OpenBB", clamped_limit) + token = await resolve_credential("OPENBB_TOKEN") + obb = _get_openbb_client(token) + result = obb.news.world(limit=clamped_limit, provider="benzinga").to_polars() + + articles = [ + OpenBBNewsArticle( + title=row.get("title"), + date=str(row.get("date")) if row.get("date") else None, + text=row.get("text"), + url=row.get("url"), + symbols=row.get("symbols"), + source=row.get("source"), + ) + for row in result.to_dicts() + ] + + return OpenBBMarketNewsResponse( + success=True, + data=OpenBBMarketNewsData( + articles=articles, + count=len(articles), + ), + ) + except Exception as e: + logger.exception("Failed to fetch market news") + return OpenBBMarketNewsResponse( + success=False, + error=f"Error fetching market news: {e}", + ) diff --git a/src/tools/finance/schemas.py b/src/tools/finance/schemas.py new file mode 100644 index 0000000..ced85a1 --- /dev/null +++ b/src/tools/finance/schemas.py @@ -0,0 +1,397 @@ +"""Pydantic output schemas for finance tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# YFinance Schemas +# ============================================================================= + + +class StockPriceData(BaseModel): + """Output data for yfinance_get_stock_price tool.""" + + symbol: str = Field(..., description="The stock ticker symbol") + price: float | None = Field(None, description="Current regular market price") + currency: str = Field("USD", description="Currency of the price") + + +class StockInfoData(BaseModel): + """Output data for yfinance_get_stock_info tool.""" + + symbol: str = Field(..., description="The stock ticker symbol") + name: str | None = Field(None, description="Company short name") + sector: str | None = Field(None, description="Company sector") + industry: str | None = Field(None, description="Company industry") + market_cap: int | None = Field(None, description="Market capitalization") + pe_ratio: float | None = Field(None, description="Trailing P/E ratio") + eps: float | None = Field(None, description="Trailing EPS") + fifty_two_week_high: float | None = Field(None, description="52-week high price") + fifty_two_week_low: float | None = Field(None, description="52-week low price") + dividend_yield: float | None = Field(None, description="Dividend yield") + summary: str | None = Field(None, description="Company business summary") + + +class HistoricalPriceEntry(BaseModel): + """A single historical price data point.""" + + date: str = Field(..., description="Date of the price data") + open: float | None = Field(None, description="Opening price") + high: float | None = Field(None, description="High price") + low: float | None = Field(None, description="Low price") + close: float | None = Field(None, description="Closing price") + volume: int | None = Field(None, description="Trading volume") + + +class HistoricalPriceData(BaseModel): + """Output data for yfinance_get_historical_data tool.""" + + symbol: str = Field(..., description="The stock ticker symbol") + period: str = Field(..., description="Time period of the data") + prices: list[HistoricalPriceEntry] = Field( + default_factory=list, description="List of historical price entries" + ) + + +class DividendEntry(BaseModel): + """A single dividend payment data point.""" + + date: str = Field(..., description="Ex-dividend date") + dividend: float = Field(..., description="Dividend amount per share") + + +class DividendData(BaseModel): + """Output data for yfinance_get_dividends tool.""" + + symbol: str = Field(..., description="The stock ticker symbol") + dividends: list[DividendEntry] = Field( + default_factory=list, description="List of dividend payments" + ) + count: int = Field(0, description="Number of dividend entries returned") + + +class OptionContract(BaseModel): + """A single option contract.""" + + contract_symbol: str | None = Field(None, description="Option contract symbol") + strike: float | None = Field(None, description="Strike price") + last_price: float | None = Field(None, description="Last traded price") + bid: float | None = Field(None, description="Bid price") + ask: float | None = Field(None, description="Ask price") + volume: int | None = Field(None, description="Trading volume") + open_interest: int | None = Field(None, description="Open interest") + implied_volatility: float | None = Field(None, description="Implied volatility") + in_the_money: bool | None = Field( + None, description="Whether the option is in the money" + ) + + +class OptionsChainData(BaseModel): + """Output data for yfinance_get_options tool.""" + + symbol: str = Field(..., description="The stock ticker symbol") + expiration: str = Field(..., description="Options expiration date") + available_expirations: list[str] = Field( + default_factory=list, description="All available expiration dates" + ) + calls: list[OptionContract] = Field( + default_factory=list, description="List of call option contracts" + ) + puts: list[OptionContract] = Field( + default_factory=list, description="List of put option contracts" + ) + + +# ============================================================================= +# OpenBB Schemas +# ============================================================================= + + +class OpenBBStockQuote(BaseModel): + """A single stock quote from OpenBB.""" + + symbol: str | None = Field(None, description="Stock ticker symbol") + last_price: float | None = Field(None, description="Last traded price") + currency: str | None = Field(None, description="Price currency") + name: str | None = Field(None, description="Company name") + high: float | None = Field(None, description="Day high price") + low: float | None = Field(None, description="Day low price") + open: float | None = Field(None, description="Day opening price") + close: float | None = Field(None, description="Previous close price") + volume: int | None = Field(None, description="Trading volume") + + +class OpenBBStockData(BaseModel): + """Output data for openbb_get_stock_data tool.""" + + quotes: list[OpenBBStockQuote] = Field( + default_factory=list, description="List of stock quotes" + ) + + +class OpenBBSearchResult(BaseModel): + """A single search result from OpenBB.""" + + symbol: str | None = Field(None, description="Stock ticker symbol") + name: str | None = Field(None, description="Company name") + + +class OpenBBSearchData(BaseModel): + """Output data for openbb_search_stocks tool.""" + + query: str = Field(..., description="The search query") + results: list[OpenBBSearchResult] = Field( + default_factory=list, description="List of matching stocks" + ) + + +# ============================================================================= +# OpenBB Market News Schemas +# ============================================================================= + + +class OpenBBNewsArticle(BaseModel): + """A single news article from OpenBB.""" + + title: str | None = Field(None, description="Article headline") + date: str | None = Field(None, description="Publication date") + text: str | None = Field(None, description="Article text or summary") + url: str | None = Field(None, description="URL to the full article") + symbols: str | None = Field(None, description="Related ticker symbols") + source: str | None = Field(None, description="News source or publisher") + + +class OpenBBMarketNewsData(BaseModel): + """Output data for openbb_get_market_news tool.""" + + articles: list[OpenBBNewsArticle] = Field( + default_factory=list, description="List of market news articles" + ) + count: int = Field(0, description="Number of articles returned") + + +# ============================================================================= +# Financial Datasets Schemas +# ============================================================================= + + +class FinancialStatement(BaseModel): + """A single financial statement entry.""" + + ticker: str = Field(..., description="Stock ticker symbol") + period: str = Field(..., description="Reporting period (annual/quarterly/ttm)") + data: dict = Field(default_factory=dict, description="Financial statement data") + + +class FinancialDatasetsFinancialsData(BaseModel): + """Output data for financial_datasets_get_financials tool.""" + + ticker: str = Field(..., description="Stock ticker symbol") + period: str = Field(..., description="Reporting period") + statements: list[FinancialStatement] = Field( + default_factory=list, description="List of financial statements" + ) + + +class PriceEntry(BaseModel): + """A single price data point from Financial Datasets.""" + + date: str | None = Field(None, description="Date of the price data") + open: float | None = Field(None, description="Opening price") + high: float | None = Field(None, description="High price") + low: float | None = Field(None, description="Low price") + close: float | None = Field(None, description="Closing price") + volume: int | None = Field(None, description="Trading volume") + + +class FinancialDatasetsPricesData(BaseModel): + """Output data for financial_datasets_get_prices tool.""" + + ticker: str = Field(..., description="Stock ticker symbol") + prices: list[PriceEntry] = Field( + default_factory=list, description="List of price data points" + ) + + +class InsiderTrade(BaseModel): + """A single insider trade record.""" + + ticker: str | None = Field(None, description="Stock ticker symbol") + company_name: str | None = Field(None, description="Company name") + insider_name: str | None = Field(None, description="Name of the insider") + insider_title: str | None = Field( + None, description="Title or position of the insider" + ) + transaction_type: str | None = Field( + None, description="Transaction type (e.g., 'Buy', 'Sell')" + ) + shares: int | None = Field(None, description="Number of shares traded") + price_per_share: float | None = Field( + None, description="Price per share at time of trade" + ) + total_value: float | None = Field( + None, description="Total value of the transaction" + ) + filing_date: str | None = Field(None, description="SEC filing date") + transaction_date: str | None = Field(None, description="Date of the transaction") + + +class FinancialDatasetsInsiderTradesData(BaseModel): + """Output data for financial_datasets_get_insider_trades tool.""" + + ticker: str = Field(..., description="Stock ticker symbol") + trades: list[InsiderTrade] = Field( + default_factory=list, description="List of insider trades" + ) + count: int = Field(0, description="Number of insider trades returned") + + +# ============================================================================= +# EVM Schemas +# ============================================================================= + + +class EvmBalanceData(BaseModel): + """Output data for evm_get_balance tool.""" + + address: str = Field(..., description="Wallet address") + balance_wei: str = Field(..., description="Balance in wei") + balance_eth: str = Field(..., description="Balance in ETH (or native token)") + chain: str = Field(..., description="Blockchain chain identifier") + + +class EvmTransactionData(BaseModel): + """Output data for evm_get_transaction tool.""" + + tx_hash: str = Field(..., description="Transaction hash") + from_address: str | None = Field(None, description="Sender address") + to_address: str | None = Field(None, description="Recipient address") + value_wei: str | None = Field(None, description="Transaction value in wei") + gas_used: int | None = Field(None, description="Gas used by the transaction") + block_number: int | None = Field(None, description="Block number") + status: int | None = Field( + None, description="Transaction status (1=success, 0=fail)" + ) + chain: str = Field(..., description="Blockchain chain identifier") + + +class EvmBlockData(BaseModel): + """Output data for evm_get_block tool.""" + + block_number: int = Field(..., description="Block number") + block_hash: str | None = Field(None, description="Block hash") + parent_hash: str | None = Field(None, description="Parent block hash") + timestamp: int | None = Field(None, description="Block timestamp (Unix epoch)") + gas_used: int | None = Field(None, description="Total gas used in the block") + gas_limit: int | None = Field(None, description="Block gas limit") + transaction_count: int = Field(0, description="Number of transactions in the block") + miner: str | None = Field(None, description="Miner/validator address") + chain: str = Field(..., description="Blockchain chain identifier") + + +class EvmGasPriceData(BaseModel): + """Output data for evm_get_gas_price tool.""" + + gas_price_wei: str = Field(..., description="Current gas price in wei") + gas_price_gwei: str = Field(..., description="Current gas price in Gwei") + chain: str = Field(..., description="Blockchain chain identifier") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class StockPriceResponse(ToolResponse[StockPriceData]): + """Response schema for yfinance_get_stock_price tool.""" + + pass + + +class StockInfoResponse(ToolResponse[StockInfoData]): + """Response schema for yfinance_get_stock_info tool.""" + + pass + + +class HistoricalPriceResponse(ToolResponse[HistoricalPriceData]): + """Response schema for yfinance_get_historical_data tool.""" + + pass + + +class OpenBBStockDataResponse(ToolResponse[OpenBBStockData]): + """Response schema for openbb_get_stock_data tool.""" + + pass + + +class OpenBBSearchResponse(ToolResponse[OpenBBSearchData]): + """Response schema for openbb_search_stocks tool.""" + + pass + + +class OpenBBMarketNewsResponse(ToolResponse[OpenBBMarketNewsData]): + """Response schema for openbb_get_market_news tool.""" + + pass + + +class FinancialDatasetsFinancialsResponse( + ToolResponse[FinancialDatasetsFinancialsData] +): + """Response schema for financial_datasets_get_financials tool.""" + + pass + + +class FinancialDatasetsPricesResponse(ToolResponse[FinancialDatasetsPricesData]): + """Response schema for financial_datasets_get_prices tool.""" + + pass + + +class EvmBalanceResponse(ToolResponse[EvmBalanceData]): + """Response schema for evm_get_balance tool.""" + + pass + + +class EvmTransactionResponse(ToolResponse[EvmTransactionData]): + """Response schema for evm_get_transaction tool.""" + + pass + + +class EvmBlockResponse(ToolResponse[EvmBlockData]): + """Response schema for evm_get_block tool.""" + + pass + + +class EvmGasPriceResponse(ToolResponse[EvmGasPriceData]): + """Response schema for evm_get_gas_price tool.""" + + pass + + +class DividendResponse(ToolResponse[DividendData]): + """Response schema for yfinance_get_dividends tool.""" + + pass + + +class OptionsChainResponse(ToolResponse[OptionsChainData]): + """Response schema for yfinance_get_options tool.""" + + pass + + +class FinancialDatasetsInsiderTradesResponse( + ToolResponse[FinancialDatasetsInsiderTradesData] +): + """Response schema for financial_datasets_get_insider_trades tool.""" + + pass diff --git a/src/tools/finance/yfinance_tool.py b/src/tools/finance/yfinance_tool.py new file mode 100644 index 0000000..2f027d7 --- /dev/null +++ b/src/tools/finance/yfinance_tool.py @@ -0,0 +1,362 @@ +"""YFinance tools for fetching stock prices, company info, and historical data.""" + +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.finance.schemas import ( + DividendData, + DividendEntry, + DividendResponse, + HistoricalPriceData, + HistoricalPriceEntry, + HistoricalPriceResponse, + OptionContract, + OptionsChainData, + OptionsChainResponse, + StockInfoData, + StockInfoResponse, + StockPriceData, + StockPriceResponse, +) + +try: + import yfinance as yf +except ImportError as err: + raise ImportError( + "yfinance is required for YFinance tools. Install with: pip install yfinance" + ) from err + +logger = logging.getLogger("humcp.tools.yfinance") + + +@tool() +async def yfinance_get_stock_price(symbol: str) -> StockPriceResponse: + """ + Get the current stock price for a given ticker symbol using Yahoo Finance. + + Args: + symbol: The stock ticker symbol (e.g., 'AAPL', 'GOOGL', 'MSFT'). + + Returns: + Current stock price data or error message. + """ + try: + logger.info("Fetching current price for %s", symbol) + stock = yf.Ticker(symbol) + info = stock.info + current_price = info.get("regularMarketPrice", info.get("currentPrice")) + currency = info.get("currency", "USD") + + if current_price is None: + return StockPriceResponse( + success=False, + error=f"Could not fetch current price for {symbol}", + ) + + return StockPriceResponse( + success=True, + data=StockPriceData( + symbol=symbol, + price=current_price, + currency=currency, + ), + ) + except Exception as e: + logger.exception("Failed to fetch stock price for %s", symbol) + return StockPriceResponse( + success=False, + error=f"Error fetching stock price for {symbol}: {e}", + ) + + +@tool() +async def yfinance_get_stock_info(symbol: str) -> StockInfoResponse: + """ + Get detailed company information for a given stock symbol using Yahoo Finance. + + Returns company name, sector, industry, market cap, P/E ratio, EPS, + 52-week range, dividend yield, and business summary. + + Args: + symbol: The stock ticker symbol (e.g., 'AAPL', 'GOOGL', 'MSFT'). + + Returns: + Company information data or error message. + """ + try: + logger.info("Fetching stock info for %s", symbol) + stock = yf.Ticker(symbol) + info = stock.info + + if not info: + return StockInfoResponse( + success=False, + error=f"Could not fetch company info for {symbol}", + ) + + return StockInfoResponse( + success=True, + data=StockInfoData( + symbol=symbol, + name=info.get("shortName"), + sector=info.get("sector"), + industry=info.get("industry"), + market_cap=info.get("marketCap"), + pe_ratio=info.get("trailingPE"), + eps=info.get("trailingEps"), + fifty_two_week_high=info.get("fiftyTwoWeekHigh"), + fifty_two_week_low=info.get("fiftyTwoWeekLow"), + dividend_yield=info.get("dividendYield"), + summary=info.get("longBusinessSummary"), + ), + ) + except Exception as e: + logger.exception("Failed to fetch stock info for %s", symbol) + return StockInfoResponse( + success=False, + error=f"Error fetching stock info for {symbol}: {e}", + ) + + +@tool() +async def yfinance_get_historical_data( + symbol: str, + period: str = "1mo", +) -> HistoricalPriceResponse: + """ + Get historical stock price data for a given symbol using Yahoo Finance. + + Args: + symbol: The stock ticker symbol (e.g., 'AAPL', 'GOOGL', 'MSFT'). + period: Time period for historical data. Valid values: 1d, 5d, 1mo, 3mo, + 6mo, 1y, 2y, 5y, 10y, ytd, max. Defaults to '1mo'. + + Returns: + Historical price data or error message. + """ + try: + valid_periods = { + "1d", + "5d", + "1mo", + "3mo", + "6mo", + "1y", + "2y", + "5y", + "10y", + "ytd", + "max", + } + if period not in valid_periods: + return HistoricalPriceResponse( + success=False, + error=f"Invalid period '{period}'. Valid periods: {', '.join(sorted(valid_periods))}", + ) + + logger.info("Fetching historical data for %s period=%s", symbol, period) + stock = yf.Ticker(symbol) + history = stock.history(period=period) + + if history.empty: + return HistoricalPriceResponse( + success=False, + error=f"No historical data found for {symbol} with period {period}", + ) + + prices = [ + HistoricalPriceEntry( + date=str(date.date()), + open=round(row.get("Open", 0), 4) + if row.get("Open") is not None + else None, + high=round(row.get("High", 0), 4) + if row.get("High") is not None + else None, + low=round(row.get("Low", 0), 4) if row.get("Low") is not None else None, + close=round(row.get("Close", 0), 4) + if row.get("Close") is not None + else None, + volume=int(row.get("Volume", 0)) + if row.get("Volume") is not None + else None, + ) + for date, row in history.iterrows() + ] + + return HistoricalPriceResponse( + success=True, + data=HistoricalPriceData( + symbol=symbol, + period=period, + prices=prices, + ), + ) + except Exception as e: + logger.exception("Failed to fetch historical data for %s", symbol) + return HistoricalPriceResponse( + success=False, + error=f"Error fetching historical data for {symbol}: {e}", + ) + + +@tool() +async def yfinance_get_dividends(symbol: str) -> DividendResponse: + """ + Get the dividend payment history for a given stock ticker symbol using Yahoo Finance. + + Returns a chronological list of ex-dividend dates and per-share dividend amounts. + Useful for analyzing income-generating stocks, tracking payout trends, and + calculating dividend yields over time. + + Args: + symbol: The stock ticker symbol (e.g., 'AAPL', 'MSFT', 'JNJ'). + + Returns: + Dividend history with dates and amounts, or error message. + """ + try: + logger.info("Fetching dividends for %s", symbol) + stock = yf.Ticker(symbol) + dividends = stock.dividends + + if dividends is None or dividends.empty: + return DividendResponse( + success=False, + error=f"No dividend data found for {symbol}. The stock may not pay dividends.", + ) + + entries = [ + DividendEntry( + date=str(date.date()), + dividend=round(float(amount), 6), + ) + for date, amount in dividends.items() + ] + + return DividendResponse( + success=True, + data=DividendData( + symbol=symbol, + dividends=entries, + count=len(entries), + ), + ) + except Exception as e: + logger.exception("Failed to fetch dividends for %s", symbol) + return DividendResponse( + success=False, + error=f"Error fetching dividends for {symbol}: {e}", + ) + + +@tool() +async def yfinance_get_options( + symbol: str, + expiration: str | None = None, +) -> OptionsChainResponse: + """ + Get the options chain for a given stock ticker symbol using Yahoo Finance. + + Retrieves call and put option contracts for a specific expiration date. + If no expiration date is provided, the nearest available expiration is used. + Each contract includes strike price, bid/ask, volume, open interest, and + implied volatility. + + Args: + symbol: The stock ticker symbol (e.g., 'AAPL', 'TSLA', 'SPY'). + expiration: Options expiration date in 'YYYY-MM-DD' format. If None, + uses the nearest available expiration date. + + Returns: + Options chain with calls and puts for the given expiration, or error message. + """ + try: + logger.info("Fetching options for %s expiration=%s", symbol, expiration) + stock = yf.Ticker(symbol) + + available_expirations = list(stock.options) if stock.options else [] + if not available_expirations: + return OptionsChainResponse( + success=False, + error=f"No options data available for {symbol}.", + ) + + selected_expiration = expiration if expiration else available_expirations[0] + + if selected_expiration not in available_expirations: + return OptionsChainResponse( + success=False, + error=f"Expiration '{selected_expiration}' not available for {symbol}. " + f"Available: {', '.join(available_expirations[:10])}", + ) + + chain = stock.option_chain(selected_expiration) + + def _parse_contracts(df) -> list[OptionContract]: + contracts: list[OptionContract] = [] + for _, row in df.iterrows(): + contracts.append( + OptionContract( + contract_symbol=row.get("contractSymbol"), + strike=row.get("strike"), + last_price=row.get("lastPrice"), + bid=row.get("bid"), + ask=row.get("ask"), + volume=int(row["volume"]) + if row.get("volume") is not None and not _is_nan(row["volume"]) + else None, + open_interest=int(row["openInterest"]) + if row.get("openInterest") is not None + and not _is_nan(row["openInterest"]) + else None, + implied_volatility=round(float(row["impliedVolatility"]), 6) + if row.get("impliedVolatility") is not None + and not _is_nan(row["impliedVolatility"]) + else None, + in_the_money=bool(row.get("inTheMoney")) + if row.get("inTheMoney") is not None + else None, + ) + ) + return contracts + + calls = ( + _parse_contracts(chain.calls) + if chain.calls is not None and not chain.calls.empty + else [] + ) + puts = ( + _parse_contracts(chain.puts) + if chain.puts is not None and not chain.puts.empty + else [] + ) + + return OptionsChainResponse( + success=True, + data=OptionsChainData( + symbol=symbol, + expiration=selected_expiration, + available_expirations=available_expirations, + calls=calls, + puts=puts, + ), + ) + except Exception as e: + logger.exception("Failed to fetch options for %s", symbol) + return OptionsChainResponse( + success=False, + error=f"Error fetching options for {symbol}: {e}", + ) + + +def _is_nan(value) -> bool: + """Check if a value is NaN.""" + try: + import math + + return math.isnan(float(value)) + except (TypeError, ValueError): + return False diff --git a/src/tools/google/google_bigquery.py b/src/tools/google/google_bigquery.py new file mode 100644 index 0000000..8e0409b --- /dev/null +++ b/src/tools/google/google_bigquery.py @@ -0,0 +1,237 @@ +"""Google BigQuery tools for querying and listing datasets and tables.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.google.schemas.bigquery import ( + BigQueryDatasetInfo, + BigQueryListDatasetsData, + BigQueryListDatasetsResponse, + BigQueryListTablesData, + BigQueryListTablesResponse, + BigQueryQueryData, + BigQueryQueryResponse, + BigQueryTableInfo, +) + +try: + from google.cloud import bigquery +except ImportError as err: + raise ImportError( + "google-cloud-bigquery is required for BigQuery tools. " + "Install with: pip install google-cloud-bigquery" + ) from err + +logger = logging.getLogger("humcp.tools.google.bigquery") + + +def _get_client( + project_id: str | None = None, resolved_project_id: str | None = None +) -> bigquery.Client: + """Create a BigQuery client. + + Uses GOOGLE_APPLICATION_CREDENTIALS environment variable for authentication. + + Args: + project_id: Optional Google Cloud project ID override from function argument. + resolved_project_id: Optional project ID resolved from credentials. + + Returns: + An authenticated BigQuery client. + """ + resolved_project = project_id or resolved_project_id + return bigquery.Client(project=resolved_project) + + +def _clean_sql(sql: str) -> str: + """Clean SQL query by normalizing whitespace. + + Replaces newlines with spaces to prevent line comments from swallowing + subsequent SQL statements. + + Args: + sql: The SQL query to clean. + + Returns: + Cleaned SQL query string. + """ + return sql.replace("\\n", " ").replace("\n", " ") + + +@tool() +async def google_bigquery_query( + query: str, + project_id: str | None = None, +) -> BigQueryQueryResponse: + """Execute a SQL query against Google BigQuery. + + Runs a BigQuery SQL query and returns the results as a list of dictionaries. + + Args: + query: The BigQuery SQL query to execute. + project_id: Optional Google Cloud project ID. Falls back to GOOGLE_CLOUD_PROJECT env var. + + Returns: + Query results with rows and metadata. + """ + try: + logger.info("Executing BigQuery query: %s", query) + + gcp_project = await resolve_credential("GOOGLE_CLOUD_PROJECT") + + def _execute() -> dict[str, Any]: + client = _get_client(project_id, gcp_project) + cleaned_query = _clean_sql(query) + query_job = client.query(cleaned_query) + results = query_job.result() + + rows = [dict(row) for row in results] + return { + "rows": rows, + "row_count": len(rows), + "total_bytes_processed": query_job.total_bytes_processed, + } + + result = await asyncio.to_thread(_execute) + + return BigQueryQueryResponse( + success=True, + data=BigQueryQueryData( + rows=result["rows"], + row_count=result["row_count"], + total_bytes_processed=result["total_bytes_processed"], + ), + ) + except Exception as e: + logger.exception("BigQuery query failed") + return BigQueryQueryResponse(success=False, error=f"BigQuery error: {e}") + + +@tool() +async def google_bigquery_list_datasets( + project_id: str | None = None, +) -> BigQueryListDatasetsResponse: + """List all datasets in a Google BigQuery project. + + Args: + project_id: Optional Google Cloud project ID. Falls back to GOOGLE_CLOUD_PROJECT env var. + + Returns: + List of datasets in the project. + """ + try: + logger.info("Listing BigQuery datasets for project: %s", project_id) + + gcp_project = await resolve_credential("GOOGLE_CLOUD_PROJECT") + + def _list() -> dict[str, Any]: + client = _get_client(project_id, gcp_project) + datasets = list(client.list_datasets()) + resolved = client.project + return { + "datasets": [ + { + "dataset_id": ds.dataset_id, + "project": ds.project, + "friendly_name": ds.friendly_name, + "description": ds.description, + } + for ds in datasets + ], + "project_id": resolved, + } + + result = await asyncio.to_thread(_list) + + datasets = [ + BigQueryDatasetInfo( + dataset_id=ds["dataset_id"], + project=ds["project"], + friendly_name=ds["friendly_name"], + description=ds["description"], + ) + for ds in result["datasets"] + ] + + return BigQueryListDatasetsResponse( + success=True, + data=BigQueryListDatasetsData( + datasets=datasets, + count=len(datasets), + project_id=result["project_id"], + ), + ) + except Exception as e: + logger.exception("BigQuery list datasets failed") + return BigQueryListDatasetsResponse(success=False, error=f"BigQuery error: {e}") + + +@tool() +async def google_bigquery_list_tables( + dataset_id: str, + project_id: str | None = None, +) -> BigQueryListTablesResponse: + """List all tables in a Google BigQuery dataset. + + Args: + dataset_id: The ID of the dataset to list tables from. + project_id: Optional Google Cloud project ID. Falls back to GOOGLE_CLOUD_PROJECT env var. + + Returns: + List of tables in the dataset. + """ + try: + logger.info( + "Listing BigQuery tables for dataset: %s project: %s", + dataset_id, + project_id, + ) + + gcp_project = await resolve_credential("GOOGLE_CLOUD_PROJECT") + + def _list() -> dict[str, Any]: + client = _get_client(project_id, gcp_project) + tables = list(client.list_tables(dataset_id)) + resolved = client.project + return { + "tables": [ + { + "table_id": t.table_id, + "dataset_id": t.dataset_id, + "project": t.project, + "table_type": t.table_type or "", + } + for t in tables + ], + "project_id": resolved, + } + + result = await asyncio.to_thread(_list) + + tables = [ + BigQueryTableInfo( + table_id=t["table_id"], + dataset_id=t["dataset_id"], + project=t["project"], + table_type=t["table_type"], + ) + for t in result["tables"] + ] + + return BigQueryListTablesResponse( + success=True, + data=BigQueryListTablesData( + tables=tables, + count=len(tables), + project_id=result["project_id"], + dataset_id=dataset_id, + ), + ) + except Exception as e: + logger.exception("BigQuery list tables failed") + return BigQueryListTablesResponse(success=False, error=f"BigQuery error: {e}") diff --git a/src/tools/google/google_maps.py b/src/tools/google/google_maps.py new file mode 100644 index 0000000..2efe17b --- /dev/null +++ b/src/tools/google/google_maps.py @@ -0,0 +1,302 @@ +"""Google Maps tools for geocoding, directions, and place search.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.google.schemas.maps import ( + DirectionRoute, + DirectionsData, + DirectionsResponse, + DirectionStep, + GeocodeData, + GeocodeResponse, + GeocodeResult, + GeoLocation, + PlaceResult, + PlacesSearchData, + PlacesSearchResponse, + ReverseGeocodeData, + ReverseGeocodeResponse, +) + +logger = logging.getLogger("humcp.tools.google.maps") + +_MAPS_API_BASE = "https://maps.googleapis.com/maps/api" + + +async def _maps_request(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: + """Make an HTTP request to the Google Maps API. + + Args: + endpoint: API endpoint path (e.g., "geocode/json"). + params: Query parameters to include. + + Returns: + Parsed JSON response. + + Raises: + httpx.HTTPStatusError: If the API returns an error HTTP status. + ValueError: If the API key is not configured. + """ + api_key = await resolve_credential("GOOGLE_MAPS_API_KEY") + if not api_key: + raise ValueError("GOOGLE_MAPS_API_KEY not configured.") + + params["key"] = api_key + url = f"{_MAPS_API_BASE}/{endpoint}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params) + response.raise_for_status() + return response.json() + + +def _parse_geocode_results(results: list[dict[str, Any]]) -> list[GeocodeResult]: + """Parse raw geocode API results into GeocodeResult models.""" + return [ + GeocodeResult( + formatted_address=r.get("formatted_address", ""), + location=GeoLocation( + lat=r.get("geometry", {}).get("location", {}).get("lat", 0.0), + lng=r.get("geometry", {}).get("location", {}).get("lng", 0.0), + ), + place_id=r.get("place_id", ""), + address_types=r.get("types", []), + ) + for r in results + ] + + +@tool() +async def google_maps_geocode(address: str) -> GeocodeResponse: + """Convert an address into geographic coordinates using Google Maps Geocoding API. + + Takes a human-readable address and returns latitude/longitude coordinates + along with formatted address information. + + Args: + address: The address to geocode (e.g., "1600 Amphitheatre Parkway, Mountain View, CA"). + + Returns: + Geocoding results with coordinates and formatted address. + """ + try: + logger.info("Geocoding address: %s", address) + + data = await _maps_request("geocode/json", {"address": address}) + + if data.get("status") != "OK": + return GeocodeResponse( + success=False, + error=f"Geocoding failed: {data.get('status')} - {data.get('error_message', '')}", + ) + + results = _parse_geocode_results(data.get("results", [])) + + return GeocodeResponse( + success=True, + data=GeocodeData( + address=address, + results=results, + result_count=len(results), + ), + ) + except Exception as e: + logger.exception("Geocoding failed") + return GeocodeResponse(success=False, error=str(e)) + + +@tool() +async def google_maps_reverse_geocode(lat: float, lng: float) -> ReverseGeocodeResponse: + """Convert geographic coordinates into an address using Google Maps Reverse Geocoding API. + + Takes latitude and longitude and returns human-readable address information. + + Args: + lat: Latitude of the location. + lng: Longitude of the location. + + Returns: + Reverse geocoding results with address information. + """ + try: + logger.info("Reverse geocoding lat=%f lng=%f", lat, lng) + + data = await _maps_request("geocode/json", {"latlng": f"{lat},{lng}"}) + + if data.get("status") != "OK": + return ReverseGeocodeResponse( + success=False, + error=f"Reverse geocoding failed: {data.get('status')} - {data.get('error_message', '')}", + ) + + results = _parse_geocode_results(data.get("results", [])) + + return ReverseGeocodeResponse( + success=True, + data=ReverseGeocodeData( + lat=lat, + lng=lng, + results=results, + result_count=len(results), + ), + ) + except Exception as e: + logger.exception("Reverse geocoding failed") + return ReverseGeocodeResponse(success=False, error=str(e)) + + +@tool() +async def google_maps_directions( + origin: str, + destination: str, + mode: str = "driving", +) -> DirectionsResponse: + """Get directions between two locations using Google Maps Directions API. + + Returns step-by-step directions, distance, and duration for a route. + + Args: + origin: Starting point address or coordinates. + destination: Destination address or coordinates. + mode: Travel mode. Options: "driving", "walking", "bicycling", "transit". Defaults to "driving". + + Returns: + Route information including steps, distance, and duration. + """ + try: + logger.info( + "Getting directions from %s to %s via %s", + origin, + destination, + mode, + ) + + data = await _maps_request( + "directions/json", + { + "origin": origin, + "destination": destination, + "mode": mode, + }, + ) + + if data.get("status") != "OK": + return DirectionsResponse( + success=False, + error=f"Directions failed: {data.get('status')} - {data.get('error_message', '')}", + ) + + routes: list[DirectionRoute] = [] + for route in data.get("routes", []): + legs = route.get("legs", [{}]) + leg = legs[0] if legs else {} + + steps = [ + DirectionStep( + instruction=step.get("html_instructions", ""), + distance=step.get("distance", {}).get("text", ""), + duration=step.get("duration", {}).get("text", ""), + travel_mode=step.get("travel_mode", ""), + ) + for step in leg.get("steps", []) + ] + + routes.append( + DirectionRoute( + summary=route.get("summary", ""), + distance=leg.get("distance", {}).get("text", ""), + duration=leg.get("duration", {}).get("text", ""), + start_address=leg.get("start_address", ""), + end_address=leg.get("end_address", ""), + steps=steps, + ) + ) + + return DirectionsResponse( + success=True, + data=DirectionsData( + origin=origin, + destination=destination, + mode=mode, + routes=routes, + ), + ) + except Exception as e: + logger.exception("Directions request failed") + return DirectionsResponse(success=False, error=str(e)) + + +@tool() +async def google_maps_search_places( + query: str, + location: str | None = None, + radius: int = 5000, +) -> PlacesSearchResponse: + """Search for places using Google Maps Places API. + + Searches for places matching the query string, optionally near a specific location. + + Args: + query: The search query (e.g., "restaurants near Central Park"). + location: Optional center point as "lat,lng" string to bias results. + radius: Search radius in meters around the location (default: 5000). + + Returns: + List of matching places with name, address, rating, and location. + """ + try: + logger.info("Searching places: %s", query) + + params: dict[str, Any] = {"query": query} + if location: + params["location"] = location + params["radius"] = radius + + data = await _maps_request("place/textsearch/json", params) + + if data.get("status") not in ("OK", "ZERO_RESULTS"): + return PlacesSearchResponse( + success=False, + error=f"Places search failed: {data.get('status')} - {data.get('error_message', '')}", + ) + + results: list[PlaceResult] = [] + for place in data.get("results", []): + geo_loc = place.get("geometry", {}).get("location", {}) + location_data = None + if geo_loc: + location_data = GeoLocation( + lat=geo_loc.get("lat", 0.0), + lng=geo_loc.get("lng", 0.0), + ) + + results.append( + PlaceResult( + name=place.get("name", ""), + address=place.get("formatted_address", ""), + place_id=place.get("place_id", ""), + rating=place.get("rating"), + user_ratings_total=place.get("user_ratings_total"), + types=place.get("types", []), + location=location_data, + ) + ) + + return PlacesSearchResponse( + success=True, + data=PlacesSearchData( + query=query, + results=results, + result_count=len(results), + ), + ) + except Exception as e: + logger.exception("Places search failed") + return PlacesSearchResponse(success=False, error=str(e)) diff --git a/src/tools/google/schemas/__init__.py b/src/tools/google/schemas/__init__.py new file mode 100644 index 0000000..36b5b2a --- /dev/null +++ b/src/tools/google/schemas/__init__.py @@ -0,0 +1,345 @@ +"""Pydantic output schemas for Google Workspace tools. + +This package contains per-service schema modules. All schemas are re-exported +here for backward compatibility with existing imports from +``src.tools.google.schemas``. +""" + +from src.tools.google.schemas.bigquery import ( + BigQueryDatasetInfo, + BigQueryListDatasetsData, + BigQueryListDatasetsResponse, + BigQueryListTablesData, + BigQueryListTablesResponse, + BigQueryQueryData, + BigQueryQueryResponse, + BigQueryTableInfo, +) +from src.tools.google.schemas.calendar import ( + CalendarCreateEventData, + CalendarCreateEventResponse, + CalendarDeleteEventData, + CalendarDeleteEventResponse, + CalendarEvent, + CalendarEventsData, + CalendarEventsResponse, + CalendarInfo, + CalendarListData, + CalendarListResponse, + CalendarQuickAddData, + CalendarQuickAddResponse, +) +from src.tools.google.schemas.chat import ( + ChatGetMessageResponse, + ChatGetMessagesData, + ChatGetMessagesResponse, + ChatGetSpaceResponse, + ChatListSpacesData, + ChatListSpacesResponse, + ChatMessage, + ChatMessageDetailed, + ChatSendMessageResponse, + ChatSentMessage, + ChatSpace, + ChatSpaceDetailed, +) +from src.tools.google.schemas.docs import ( + DocInfo, + DocsAppendTextData, + DocsAppendTextResponse, + DocsCreateData, + DocsCreateResponse, + DocsFindReplaceData, + DocsFindReplaceResponse, + DocsGetContentData, + DocsGetContentResponse, + DocsListInFolderData, + DocsListInFolderResponse, + DocsSearchData, + DocsSearchResponse, +) +from src.tools.google.schemas.drive import ( + DriveCreateFolderData, + DriveCreateFolderResponse, + DriveFile, + DriveFileDetailed, + DriveFileOwner, + DriveGetFileResponse, + DriveListData, + DriveListResponse, + DriveReadTextFileData, + DriveReadTextFileResponse, + DriveSearchData, + DriveSearchResponse, +) +from src.tools.google.schemas.forms import ( + FormAnswer, + FormCreated, + FormDetailed, + FormFileAnswer, + FormInfo, + FormQuestion, + FormResponseDetailed, + FormResponseSummary, + FormsCreateFormResponse, + FormsGetFormResponse, + FormsGetResponseResponse, + FormsListData, + FormsListResponse, + FormsListResponsesData, + FormsListResponsesResponse, +) +from src.tools.google.schemas.gmail import ( + GmailLabel, + GmailLabelsData, + GmailLabelsResponse, + GmailMessage, + GmailMessageFull, + GmailReadResponse, + GmailSearchData, + GmailSearchResponse, + GmailSendData, + GmailSendResponse, + GmailThread, + GmailThreadsData, + GmailThreadsResponse, +) +from src.tools.google.schemas.maps import ( + DirectionRoute, + DirectionsData, + DirectionsResponse, + DirectionStep, + GeocodeData, + GeocodeResponse, + GeocodeResult, + GeoLocation, + PlaceResult, + PlacesSearchData, + PlacesSearchResponse, + ReverseGeocodeData, + ReverseGeocodeResponse, +) +from src.tools.google.schemas.sheets import ( + SheetInfo, + SheetsAddSheetData, + SheetsAddSheetResponse, + SheetsAppendValuesData, + SheetsAppendValuesResponse, + SheetsClearValuesData, + SheetsClearValuesResponse, + SheetsCreateData, + SheetsCreateResponse, + SheetsGetInfoData, + SheetsGetInfoResponse, + SheetsListData, + SheetsListResponse, + SheetsReadValuesData, + SheetsReadValuesResponse, + SheetsWriteValuesData, + SheetsWriteValuesResponse, + SpreadsheetInfo, +) +from src.tools.google.schemas.slides import ( + PresentationCreated, + PresentationDetailed, + PresentationInfo, + SlideAdded, + SlideElement, + SlideInfo, + SlidesAddSlideResponse, + SlidesAddTextResponse, + SlidesCreatePresentationResponse, + SlidesGetPresentationResponse, + SlidesGetThumbnailResponse, + SlidesListData, + SlidesListPresentationsResponse, + SlideThumbnail, + TextAdded, +) +from src.tools.google.schemas.tasks import ( + TaskCreated, + TaskDeleted, + TaskDetailed, + TaskInfo, + TaskListDeleted, + TaskListInfo, + TasksClearCompletedResponse, + TasksClearedCompleted, + TasksCreateTaskListResponse, + TasksCreateTaskResponse, + TasksDeleteTaskListResponse, + TasksDeleteTaskResponse, + TasksGetTaskListResponse, + TasksGetTaskResponse, + TasksListTaskListsData, + TasksListTaskListsResponse, + TasksListTasksData, + TasksListTasksResponse, + TasksUpdateTaskResponse, + TaskUpdated, +) + +__all__ = [ + # Drive + "DriveCreateFolderData", + "DriveCreateFolderResponse", + "DriveFile", + "DriveFileDetailed", + "DriveFileOwner", + "DriveGetFileResponse", + "DriveListData", + "DriveListResponse", + "DriveReadTextFileData", + "DriveReadTextFileResponse", + "DriveSearchData", + "DriveSearchResponse", + # Gmail + "GmailLabel", + "GmailLabelsData", + "GmailLabelsResponse", + "GmailMessage", + "GmailMessageFull", + "GmailReadResponse", + "GmailSearchData", + "GmailSearchResponse", + "GmailSendData", + "GmailSendResponse", + "GmailThread", + "GmailThreadsData", + "GmailThreadsResponse", + # Calendar + "CalendarCreateEventData", + "CalendarCreateEventResponse", + "CalendarDeleteEventData", + "CalendarDeleteEventResponse", + "CalendarEvent", + "CalendarEventsData", + "CalendarEventsResponse", + "CalendarInfo", + "CalendarListData", + "CalendarListResponse", + "CalendarQuickAddData", + "CalendarQuickAddResponse", + # Sheets + "SheetInfo", + "SheetsAddSheetData", + "SheetsAddSheetResponse", + "SheetsAppendValuesData", + "SheetsAppendValuesResponse", + "SheetsClearValuesData", + "SheetsClearValuesResponse", + "SheetsCreateData", + "SheetsCreateResponse", + "SheetsGetInfoData", + "SheetsGetInfoResponse", + "SheetsListData", + "SheetsListResponse", + "SheetsReadValuesData", + "SheetsReadValuesResponse", + "SheetsWriteValuesData", + "SheetsWriteValuesResponse", + "SpreadsheetInfo", + # Docs + "DocInfo", + "DocsAppendTextData", + "DocsAppendTextResponse", + "DocsCreateData", + "DocsCreateResponse", + "DocsFindReplaceData", + "DocsFindReplaceResponse", + "DocsGetContentData", + "DocsGetContentResponse", + "DocsListInFolderData", + "DocsListInFolderResponse", + "DocsSearchData", + "DocsSearchResponse", + # Chat + "ChatGetMessageResponse", + "ChatGetMessagesData", + "ChatGetMessagesResponse", + "ChatGetSpaceResponse", + "ChatListSpacesData", + "ChatListSpacesResponse", + "ChatMessage", + "ChatMessageDetailed", + "ChatSendMessageResponse", + "ChatSentMessage", + "ChatSpace", + "ChatSpaceDetailed", + # Forms + "FormAnswer", + "FormCreated", + "FormDetailed", + "FormFileAnswer", + "FormInfo", + "FormQuestion", + "FormResponseDetailed", + "FormResponseSummary", + "FormsCreateFormResponse", + "FormsGetFormResponse", + "FormsGetResponseResponse", + "FormsListData", + "FormsListResponse", + "FormsListResponsesData", + "FormsListResponsesResponse", + # Slides + "PresentationCreated", + "PresentationDetailed", + "PresentationInfo", + "SlideAdded", + "SlideElement", + "SlideInfo", + "SlidesAddSlideResponse", + "SlidesAddTextResponse", + "SlidesCreatePresentationResponse", + "SlidesGetPresentationResponse", + "SlidesGetThumbnailResponse", + "SlidesListData", + "SlidesListPresentationsResponse", + "SlideThumbnail", + "TextAdded", + # Maps + "DirectionRoute", + "DirectionStep", + "DirectionsData", + "DirectionsResponse", + "GeocodeData", + "GeocodeResponse", + "GeocodeResult", + "GeoLocation", + "PlaceResult", + "PlacesSearchData", + "PlacesSearchResponse", + "ReverseGeocodeData", + "ReverseGeocodeResponse", + # BigQuery + "BigQueryDatasetInfo", + "BigQueryListDatasetsData", + "BigQueryListDatasetsResponse", + "BigQueryListTablesData", + "BigQueryListTablesResponse", + "BigQueryQueryData", + "BigQueryQueryResponse", + "BigQueryTableInfo", + # Tasks + "TaskCreated", + "TaskDeleted", + "TaskDetailed", + "TaskInfo", + "TaskListDeleted", + "TaskListInfo", + "TasksClearCompletedResponse", + "TasksClearedCompleted", + "TasksCreateTaskListResponse", + "TasksCreateTaskResponse", + "TasksDeleteTaskListResponse", + "TasksDeleteTaskResponse", + "TasksGetTaskListResponse", + "TasksGetTaskResponse", + "TasksListTaskListsData", + "TasksListTaskListsResponse", + "TasksListTasksData", + "TasksListTasksResponse", + "TasksUpdateTaskResponse", + "TaskUpdated", +] diff --git a/src/tools/google/schemas/bigquery.py b/src/tools/google/schemas/bigquery.py new file mode 100644 index 0000000..c7924e9 --- /dev/null +++ b/src/tools/google/schemas/bigquery.py @@ -0,0 +1,132 @@ +"""Pydantic output schemas for Google BigQuery tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# BigQuery Query Schemas +# ============================================================================= + + +class BigQueryQueryData(BaseModel): + """Output data for google_bigquery_query tool.""" + + rows: list[dict[str, Any]] = Field( + default_factory=list, description="Query result rows" + ) + row_count: int = Field(..., description="Number of rows returned") + total_bytes_processed: int | None = Field( + None, description="Total bytes processed by the query" + ) + + +# ============================================================================= +# BigQuery List Datasets Schemas +# ============================================================================= + + +class BigQueryDatasetInfo(BaseModel): + """Information about a BigQuery dataset.""" + + dataset_id: str = Field(..., description="Dataset ID") + project: str = Field(..., description="Project ID") + friendly_name: str | None = Field(None, description="Dataset display name") + description: str | None = Field(None, description="Dataset description") + + +class BigQueryListDatasetsData(BaseModel): + """Output data for google_bigquery_list_datasets tool.""" + + datasets: list[BigQueryDatasetInfo] = Field(..., description="List of datasets") + count: int = Field(..., description="Number of datasets") + project_id: str = Field(..., description="Google Cloud project ID") + + +# ============================================================================= +# BigQuery List Tables Schemas +# ============================================================================= + + +class BigQueryTableInfo(BaseModel): + """Information about a BigQuery table.""" + + table_id: str = Field(..., description="Table ID") + dataset_id: str = Field(..., description="Dataset ID") + project: str = Field(..., description="Project ID") + table_type: str = Field("", description="Table type (TABLE, VIEW, etc.)") + + +class BigQueryListTablesData(BaseModel): + """Output data for google_bigquery_list_tables tool.""" + + tables: list[BigQueryTableInfo] = Field(..., description="List of tables") + count: int = Field(..., description="Number of tables") + project_id: str = Field(..., description="Google Cloud project ID") + dataset_id: str = Field(..., description="Dataset ID") + + +# ============================================================================= +# BigQuery Table Schema Schemas +# ============================================================================= + + +class BigQueryFieldInfo(BaseModel): + """Information about a single column/field in a BigQuery table.""" + + name: str = Field(..., description="Field name") + field_type: str = Field(..., description="Field data type (STRING, INTEGER, etc.)") + mode: str = Field( + "NULLABLE", description="Field mode (NULLABLE, REQUIRED, REPEATED)" + ) + description: str = Field("", description="Field description") + fields: list["BigQueryFieldInfo"] = Field( + default_factory=list, description="Nested fields for RECORD types" + ) + + +class BigQueryTableSchemaData(BaseModel): + """Output data for google_bigquery_get_table_schema tool.""" + + project_id: str = Field(..., description="Google Cloud project ID") + dataset_id: str = Field(..., description="Dataset ID") + table_id: str = Field(..., description="Table ID") + fields: list[BigQueryFieldInfo] = Field( + ..., description="List of table fields/columns" + ) + total_fields: int = Field(..., description="Total number of top-level fields") + num_rows: int | None = Field(None, description="Number of rows in the table") + num_bytes: int | None = Field(None, description="Size of the table in bytes") + table_type: str = Field("", description="Table type (TABLE, VIEW, etc.)") + description: str = Field("", description="Table description") + + +# ============================================================================= +# Response Wrappers +# ============================================================================= + + +class BigQueryQueryResponse(ToolResponse[BigQueryQueryData]): + """Response schema for google_bigquery_query tool.""" + + pass + + +class BigQueryListDatasetsResponse(ToolResponse[BigQueryListDatasetsData]): + """Response schema for google_bigquery_list_datasets tool.""" + + pass + + +class BigQueryListTablesResponse(ToolResponse[BigQueryListTablesData]): + """Response schema for google_bigquery_list_tables tool.""" + + pass + + +class BigQueryTableSchemaResponse(ToolResponse[BigQueryTableSchemaData]): + """Response schema for google_bigquery_get_table_schema tool.""" + + pass diff --git a/src/tools/google/schemas/calendar.py b/src/tools/google/schemas/calendar.py new file mode 100644 index 0000000..6070e71 --- /dev/null +++ b/src/tools/google/schemas/calendar.py @@ -0,0 +1,99 @@ +"""Pydantic output schemas for Google Calendar tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class CalendarInfo(BaseModel): + """Information about a calendar.""" + + id: str = Field(..., description="Calendar ID") + name: str = Field("", description="Calendar name") + description: str = Field("", description="Calendar description") + primary: bool = Field(False, description="Whether this is the primary calendar") + access_role: str = Field("", description="User's access role") + + +class CalendarEvent(BaseModel): + """Information about a calendar event.""" + + id: str = Field(..., description="Event ID") + title: str = Field(..., description="Event title") + description: str = Field("", description="Event description") + start: str = Field(..., description="Start time (ISO format)") + end: str = Field(..., description="End time (ISO format)") + location: str = Field("", description="Event location") + status: str = Field("", description="Event status") + html_link: str = Field("", description="Link to event") + + +class CalendarListData(BaseModel): + """Output data for google_calendar_list tool.""" + + calendars: list[CalendarInfo] = Field(..., description="List of calendars") + total: int = Field(..., description="Total number of calendars") + + +class CalendarEventsData(BaseModel): + """Output data for google_calendar_events tool.""" + + events: list[CalendarEvent] = Field(..., description="List of events") + total: int = Field(..., description="Total number of events") + + +class CalendarCreateEventData(BaseModel): + """Output data for google_calendar_create_event tool.""" + + id: str = Field(..., description="Created event ID") + title: str | None = Field(None, description="Event title") + start: str | None = Field(None, description="Start time") + end: str | None = Field(None, description="End time") + html_link: str | None = Field(None, description="Event link") + + +class CalendarDeleteEventData(BaseModel): + """Output data for google_calendar_delete_event tool.""" + + deleted_event_id: str = Field(..., description="ID of deleted event") + + +class CalendarQuickAddData(BaseModel): + """Output data for google_calendar_quick_add tool.""" + + id: str = Field(..., description="Created event ID") + title: str | None = Field(None, description="Event title parsed from text") + start: str | None = Field(None, description="Start time") + end: str | None = Field(None, description="End time") + html_link: str | None = Field(None, description="Event link") + + +# Calendar Responses +class CalendarListResponse(ToolResponse[CalendarListData]): + """Response for google_calendar_list tool.""" + + pass + + +class CalendarEventsResponse(ToolResponse[CalendarEventsData]): + """Response for google_calendar_events tool.""" + + pass + + +class CalendarCreateEventResponse(ToolResponse[CalendarCreateEventData]): + """Response for google_calendar_create_event tool.""" + + pass + + +class CalendarDeleteEventResponse(ToolResponse[CalendarDeleteEventData]): + """Response for google_calendar_delete_event tool.""" + + pass + + +class CalendarQuickAddResponse(ToolResponse[CalendarQuickAddData]): + """Response for google_calendar_quick_add tool.""" + + pass diff --git a/src/tools/google/schemas/chat.py b/src/tools/google/schemas/chat.py new file mode 100644 index 0000000..8572284 --- /dev/null +++ b/src/tools/google/schemas/chat.py @@ -0,0 +1,105 @@ +"""Pydantic output schemas for Google Chat tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class ChatSpace(BaseModel): + """Information about a Google Chat space.""" + + name: str = Field(..., description="Space resource name") + display_name: str = Field("", description="Display name of the space") + type: str = Field("", description="Space type (ROOM, DIRECT_MESSAGE)") + single_user_bot_dm: bool = Field(False, description="Whether this is a bot DM") + threaded: bool = Field(False, description="Whether the space is threaded") + + +class ChatSpaceDetailed(BaseModel): + """Detailed information about a Google Chat space.""" + + name: str = Field(..., description="Space resource name") + display_name: str = Field("", description="Display name of the space") + type: str = Field("", description="Space type") + single_user_bot_dm: bool = Field(False, description="Whether this is a bot DM") + threaded: bool = Field(False, description="Whether the space is threaded") + external_user_allowed: bool = Field( + False, description="Whether external users allowed" + ) + + +class ChatMessage(BaseModel): + """Information about a Google Chat message.""" + + name: str = Field(..., description="Message resource name") + text: str = Field("", description="Message text content") + sender: str = Field("", description="Sender display name") + sender_type: str = Field("", description="Sender type (HUMAN, BOT)") + created: str = Field("", description="Creation time") + thread_name: str = Field("", description="Thread resource name") + + +class ChatMessageDetailed(BaseModel): + """Detailed information about a Google Chat message.""" + + name: str = Field(..., description="Message resource name") + text: str = Field("", description="Message text content") + sender: str = Field("", description="Sender display name") + sender_type: str = Field("", description="Sender type") + created: str = Field("", description="Creation time") + thread_name: str = Field("", description="Thread resource name") + space_name: str = Field("", description="Space resource name") + + +class ChatSentMessage(BaseModel): + """Information about a sent Google Chat message.""" + + name: str = Field(..., description="Message resource name") + text: str = Field("", description="Message text content") + created: str = Field("", description="Creation time") + thread_name: str = Field("", description="Thread resource name") + + +class ChatListSpacesData(BaseModel): + """Output data for google_chat_list_spaces tool.""" + + spaces: list[ChatSpace] = Field(..., description="List of spaces") + total: int = Field(..., description="Total number of spaces") + + +class ChatGetMessagesData(BaseModel): + """Output data for google_chat_get_messages tool.""" + + messages: list[ChatMessage] = Field(..., description="List of messages") + total: int = Field(..., description="Total number of messages") + + +# Chat Responses +class ChatListSpacesResponse(ToolResponse[ChatListSpacesData]): + """Response for google_chat_list_spaces tool.""" + + pass + + +class ChatGetSpaceResponse(ToolResponse[ChatSpaceDetailed]): + """Response for google_chat_get_space tool.""" + + pass + + +class ChatGetMessagesResponse(ToolResponse[ChatGetMessagesData]): + """Response for google_chat_get_messages tool.""" + + pass + + +class ChatGetMessageResponse(ToolResponse[ChatMessageDetailed]): + """Response for google_chat_get_message tool.""" + + pass + + +class ChatSendMessageResponse(ToolResponse[ChatSentMessage]): + """Response for google_chat_send_message tool.""" + + pass diff --git a/src/tools/google/schemas/docs.py b/src/tools/google/schemas/docs.py new file mode 100644 index 0000000..c126353 --- /dev/null +++ b/src/tools/google/schemas/docs.py @@ -0,0 +1,98 @@ +"""Pydantic output schemas for Google Docs tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class DocInfo(BaseModel): + """Basic document information.""" + + id: str = Field(..., description="Document ID") + name: str = Field(..., description="Document name") + modified: str = Field("", description="Last modified date") + web_link: str = Field("", description="Web view link") + + +class DocsSearchData(BaseModel): + """Output data for google_docs_search tool.""" + + documents: list[DocInfo] = Field(..., description="List of documents") + total: int = Field(..., description="Total number of documents") + + +class DocsGetContentData(BaseModel): + """Output data for google_docs_get_content tool.""" + + id: str = Field(..., description="Document ID") + title: str = Field(..., description="Document title") + content: str = Field(..., description="Document text content") + revision_id: str = Field("", description="Document revision ID") + + +class DocsCreateData(BaseModel): + """Output data for google_docs_create tool.""" + + id: str = Field(..., description="Created document ID") + title: str = Field(..., description="Document title") + web_link: str = Field(..., description="Document link") + + +class DocsAppendTextData(BaseModel): + """Output data for google_docs_append_text tool.""" + + updated: bool = Field(..., description="Whether update succeeded") + document_id: str = Field(..., description="Document ID") + + +class DocsFindReplaceData(BaseModel): + """Output data for google_docs_find_replace tool.""" + + document_id: str = Field(..., description="Document ID") + find_text: str = Field(..., description="Text that was searched") + replace_text: str = Field(..., description="Replacement text") + replacements: int = Field(..., description="Number of replacements made") + + +class DocsListInFolderData(BaseModel): + """Output data for google_docs_list_in_folder tool.""" + + documents: list[DocInfo] = Field(..., description="List of documents") + total: int = Field(..., description="Total number of documents") + + +# Docs Responses +class DocsSearchResponse(ToolResponse[DocsSearchData]): + """Response for google_docs_search tool.""" + + pass + + +class DocsGetContentResponse(ToolResponse[DocsGetContentData]): + """Response for google_docs_get_content tool.""" + + pass + + +class DocsCreateResponse(ToolResponse[DocsCreateData]): + """Response for google_docs_create tool.""" + + pass + + +class DocsAppendTextResponse(ToolResponse[DocsAppendTextData]): + """Response for google_docs_append_text tool.""" + + pass + + +class DocsFindReplaceResponse(ToolResponse[DocsFindReplaceData]): + """Response for google_docs_find_replace tool.""" + + pass + + +class DocsListInFolderResponse(ToolResponse[DocsListInFolderData]): + """Response for google_docs_list_in_folder tool.""" + + pass diff --git a/src/tools/google/schemas/drive.py b/src/tools/google/schemas/drive.py new file mode 100644 index 0000000..1ab53d0 --- /dev/null +++ b/src/tools/google/schemas/drive.py @@ -0,0 +1,108 @@ +"""Pydantic output schemas for Google Drive tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class DriveFile(BaseModel): + """Information about a Google Drive file.""" + + id: str = Field(..., description="File ID") + name: str = Field(..., description="File name") + mime_type: str = Field("", description="MIME type of the file") + size: str = Field("", description="File size in bytes") + modified: str = Field("", description="Last modified date") + web_link: str = Field("", description="Web view link") + owner: str | None = Field(None, description="Owner email address") + + +class DriveFileOwner(BaseModel): + """Owner information for a file.""" + + name: str | None = Field(None, description="Owner display name") + email: str | None = Field(None, description="Owner email address") + + +class DriveFileDetailed(BaseModel): + """Detailed file information from google_drive_get_file.""" + + id: str = Field(..., description="File ID") + name: str = Field(..., description="File name") + mime_type: str = Field("", description="MIME type") + size: str = Field("", description="File size") + created: str = Field("", description="Creation date") + modified: str = Field("", description="Last modified date") + description: str = Field("", description="File description") + web_link: str = Field("", description="Web view link") + download_link: str = Field("", description="Direct download link") + owners: list[DriveFileOwner] = Field( + default_factory=list, description="File owners" + ) + parent_folders: list[str] = Field( + default_factory=list, description="Parent folder IDs" + ) + + +class DriveListData(BaseModel): + """Output data for google_drive_list tool.""" + + files: list[DriveFile] = Field(..., description="List of files") + total: int = Field(..., description="Total number of files") + + +class DriveSearchData(BaseModel): + """Output data for google_drive_search tool.""" + + files: list[DriveFile] = Field(..., description="List of matching files") + total: int = Field(..., description="Total number of results") + query: str = Field(..., description="Search query used") + + +class DriveReadTextFileData(BaseModel): + """Output data for google_drive_read_text_file tool.""" + + name: str = Field(..., description="File name") + mime_type: str = Field(..., description="MIME type") + content: str = Field(..., description="File text content") + length: int = Field(..., description="Content length in characters") + + +class DriveCreateFolderData(BaseModel): + """Output data for google_drive_create_folder tool.""" + + id: str = Field(..., description="Created folder ID") + name: str = Field(..., description="Folder name") + web_link: str = Field("", description="Web view link to the folder") + parent_id: str = Field("", description="Parent folder ID") + + +# Drive Responses +class DriveListResponse(ToolResponse[DriveListData]): + """Response for google_drive_list tool.""" + + pass + + +class DriveSearchResponse(ToolResponse[DriveSearchData]): + """Response for google_drive_search tool.""" + + pass + + +class DriveGetFileResponse(ToolResponse[DriveFileDetailed]): + """Response for google_drive_get_file tool.""" + + pass + + +class DriveReadTextFileResponse(ToolResponse[DriveReadTextFileData]): + """Response for google_drive_read_text_file tool.""" + + pass + + +class DriveCreateFolderResponse(ToolResponse[DriveCreateFolderData]): + """Response for google_drive_create_folder tool.""" + + pass diff --git a/src/tools/google/schemas/forms.py b/src/tools/google/schemas/forms.py new file mode 100644 index 0000000..9dc49fe --- /dev/null +++ b/src/tools/google/schemas/forms.py @@ -0,0 +1,132 @@ +"""Pydantic output schemas for Google Forms tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class FormInfo(BaseModel): + """Basic form information.""" + + id: str = Field(..., description="Form ID") + name: str = Field(..., description="Form name") + modified: str = Field("", description="Last modified date") + web_link: str = Field("", description="Web view link") + + +class FormQuestion(BaseModel): + """Information about a form question.""" + + id: str = Field("", description="Question ID") + title: str = Field("", description="Question title") + required: bool = Field(False, description="Whether the question is required") + type: str = Field("unknown", description="Question type") + paragraph: bool = Field(False, description="Whether it's a paragraph text question") + options: list[str] = Field(default_factory=list, description="Choice options") + low: int = Field(1, description="Scale low value") + high: int = Field(5, description="Scale high value") + + +class FormDetailed(BaseModel): + """Detailed form information.""" + + id: str = Field(..., description="Form ID") + title: str = Field("", description="Form title") + description: str = Field("", description="Form description") + document_title: str = Field("", description="Document title in Drive") + responder_uri: str = Field("", description="Responder URI") + questions: list[FormQuestion] = Field( + default_factory=list, description="Form questions" + ) + question_count: int = Field(0, description="Number of questions") + + +class FormCreated(BaseModel): + """Information about a created form.""" + + id: str = Field(..., description="Form ID") + title: str = Field("", description="Form title") + document_title: str = Field("", description="Document title") + responder_uri: str = Field("", description="Responder URI") + edit_uri: str = Field("", description="Edit URI") + + +class FormResponseSummary(BaseModel): + """Summary of a form response.""" + + id: str = Field("", description="Response ID") + created: str = Field("", description="Creation time") + last_submitted: str = Field("", description="Last submitted time") + answer_count: int = Field(0, description="Number of answers") + + +class FormFileAnswer(BaseModel): + """File upload answer in a form response.""" + + id: str | None = Field(None, description="File ID") + name: str | None = Field(None, description="File name") + + +class FormAnswer(BaseModel): + """Answer in a form response.""" + + question_id: str = Field(..., description="Question ID") + type: str = Field("", description="Answer type (text, file)") + values: list[str] = Field(default_factory=list, description="Text answer values") + files: list[FormFileAnswer] = Field( + default_factory=list, description="File upload answers" + ) + + +class FormResponseDetailed(BaseModel): + """Detailed form response information.""" + + response_id: str = Field("", description="Response ID") + created: str = Field("", description="Creation time") + last_submitted: str = Field("", description="Last submitted time") + answers: list[FormAnswer] = Field(default_factory=list, description="Answers") + + +class FormsListData(BaseModel): + """Output data for google_forms_list_forms tool.""" + + forms: list[FormInfo] = Field(..., description="List of forms") + total: int = Field(..., description="Total number of forms") + + +class FormsListResponsesData(BaseModel): + """Output data for google_forms_list_responses tool.""" + + responses: list[FormResponseSummary] = Field(..., description="List of responses") + total: int = Field(..., description="Total number of responses") + + +# Forms Responses +class FormsListResponse(ToolResponse[FormsListData]): + """Response for google_forms_list_forms tool.""" + + pass + + +class FormsGetFormResponse(ToolResponse[FormDetailed]): + """Response for google_forms_get_form tool.""" + + pass + + +class FormsCreateFormResponse(ToolResponse[FormCreated]): + """Response for google_forms_create_form tool.""" + + pass + + +class FormsListResponsesResponse(ToolResponse[FormsListResponsesData]): + """Response for google_forms_list_responses tool.""" + + pass + + +class FormsGetResponseResponse(ToolResponse[FormResponseDetailed]): + """Response for google_forms_get_response tool.""" + + pass diff --git a/src/tools/google/schemas/gmail.py b/src/tools/google/schemas/gmail.py new file mode 100644 index 0000000..96d09bd --- /dev/null +++ b/src/tools/google/schemas/gmail.py @@ -0,0 +1,113 @@ +"""Pydantic output schemas for Gmail tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class GmailMessage(BaseModel): + """Summary of a Gmail message.""" + + id: str = Field(..., description="Message ID") + thread_id: str | None = Field(None, description="Thread ID") + subject: str = Field(..., description="Email subject") + from_: str = Field("", alias="from", description="Sender address") + to: str = Field("", description="Recipient address") + date: str = Field("", description="Date sent") + snippet: str = Field("", description="Message preview snippet") + + +class GmailMessageFull(BaseModel): + """Full Gmail message content.""" + + id: str = Field(..., description="Message ID") + thread_id: str | None = Field(None, description="Thread ID") + subject: str = Field(..., description="Email subject") + from_: str = Field("", alias="from", description="Sender address") + to: str = Field("", description="Recipient address") + cc: str = Field("", description="CC recipients") + date: str = Field("", description="Date sent") + body: str = Field("", description="Message body text") + labels: list[str] = Field(default_factory=list, description="Gmail labels") + + +class GmailSearchData(BaseModel): + """Output data for google_gmail_search tool.""" + + messages: list[dict[str, Any]] = Field(..., description="List of messages") + total: int = Field(..., description="Total number of messages") + + +class GmailSendData(BaseModel): + """Output data for google_gmail_send tool.""" + + message_id: str | None = Field(None, description="Sent message ID") + thread_id: str | None = Field(None, description="Thread ID") + + +class GmailLabel(BaseModel): + """Gmail label information.""" + + id: str = Field(..., description="Label ID") + name: str = Field(..., description="Label name") + + +class GmailLabelsData(BaseModel): + """Output data for google_gmail_labels tool.""" + + labels: list[GmailLabel] = Field(..., description="List of labels") + total: int = Field(..., description="Total number of labels") + + +class GmailThread(BaseModel): + """Summary of a Gmail thread.""" + + id: str = Field(..., description="Thread ID") + snippet: str = Field("", description="Thread preview snippet") + history_id: str | None = Field(None, description="History ID") + message_count: int = Field(0, description="Number of messages in the thread") + subject: str = Field("", description="Thread subject from first message") + last_date: str = Field("", description="Date of most recent message") + + +class GmailThreadsData(BaseModel): + """Output data for google_gmail_list_threads tool.""" + + threads: list[GmailThread] = Field(..., description="List of threads") + total: int = Field(..., description="Total number of threads returned") + estimated_total: int | None = Field( + None, description="Estimated total threads matching the query" + ) + + +# Gmail Responses +class GmailSearchResponse(ToolResponse[GmailSearchData]): + """Response for google_gmail_search tool.""" + + pass + + +class GmailReadResponse(ToolResponse[GmailMessageFull]): + """Response for google_gmail_read tool.""" + + pass + + +class GmailSendResponse(ToolResponse[GmailSendData]): + """Response for google_gmail_send tool.""" + + pass + + +class GmailLabelsResponse(ToolResponse[GmailLabelsData]): + """Response for google_gmail_labels tool.""" + + pass + + +class GmailThreadsResponse(ToolResponse[GmailThreadsData]): + """Response for google_gmail_list_threads tool.""" + + pass diff --git a/src/tools/google/schemas/maps.py b/src/tools/google/schemas/maps.py new file mode 100644 index 0000000..8fb4090 --- /dev/null +++ b/src/tools/google/schemas/maps.py @@ -0,0 +1,172 @@ +"""Pydantic output schemas for Google Maps tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Geocode Schemas +# ============================================================================= + + +class GeoLocation(BaseModel): + """Geographic coordinates.""" + + lat: float = Field(..., description="Latitude") + lng: float = Field(..., description="Longitude") + + +class GeocodeResult(BaseModel): + """A single geocoding result.""" + + formatted_address: str = Field(..., description="Formatted address string") + location: GeoLocation = Field(..., description="Geographic coordinates") + place_id: str = Field("", description="Google Place ID") + address_types: list[str] = Field( + default_factory=list, description="Address type classifications" + ) + + +class GeocodeData(BaseModel): + """Output data for google_maps_geocode tool.""" + + address: str = Field(..., description="The input address that was geocoded") + results: list[GeocodeResult] = Field(..., description="List of geocoding results") + result_count: int = Field(..., description="Number of results returned") + + +class ReverseGeocodeData(BaseModel): + """Output data for google_maps_reverse_geocode tool.""" + + lat: float = Field(..., description="Input latitude") + lng: float = Field(..., description="Input longitude") + results: list[GeocodeResult] = Field( + ..., description="List of reverse geocoding results" + ) + result_count: int = Field(..., description="Number of results returned") + + +# ============================================================================= +# Directions Schemas +# ============================================================================= + + +class DirectionStep(BaseModel): + """A single step in a route.""" + + instruction: str = Field(..., description="HTML instruction text") + distance: str = Field(..., description="Distance text (e.g., '1.2 km')") + duration: str = Field(..., description="Duration text (e.g., '5 mins')") + travel_mode: str = Field("", description="Travel mode for this step") + + +class DirectionRoute(BaseModel): + """A single route from directions response.""" + + summary: str = Field("", description="Route summary") + distance: str = Field(..., description="Total distance text") + duration: str = Field(..., description="Total duration text") + start_address: str = Field("", description="Starting address") + end_address: str = Field("", description="Ending address") + steps: list[DirectionStep] = Field(default_factory=list, description="Route steps") + + +class DirectionsData(BaseModel): + """Output data for google_maps_directions tool.""" + + origin: str = Field(..., description="Origin address or coordinates") + destination: str = Field(..., description="Destination address or coordinates") + mode: str = Field(..., description="Travel mode used") + routes: list[DirectionRoute] = Field(..., description="List of possible routes") + + +# ============================================================================= +# Places Search Schemas +# ============================================================================= + + +class PlaceResult(BaseModel): + """A single place from search results.""" + + name: str = Field(..., description="Place name") + address: str = Field("", description="Formatted address") + place_id: str = Field("", description="Google Place ID") + rating: float | None = Field(None, description="Average rating") + user_ratings_total: int | None = Field( + None, description="Total number of user ratings" + ) + types: list[str] = Field( + default_factory=list, description="Place type classifications" + ) + location: GeoLocation | None = Field(None, description="Geographic coordinates") + + +class PlacesSearchData(BaseModel): + """Output data for google_maps_search_places tool.""" + + query: str = Field(..., description="The search query used") + results: list[PlaceResult] = Field(..., description="List of place results") + result_count: int = Field(..., description="Number of results returned") + + +# ============================================================================= +# Distance Matrix Schemas +# ============================================================================= + + +class DistanceMatrixElement(BaseModel): + """A single origin-destination pair result.""" + + origin: str = Field(..., description="Origin address") + destination: str = Field(..., description="Destination address") + distance: str = Field("", description="Distance text (e.g., '12.3 km')") + distance_meters: int | None = Field(None, description="Distance in meters") + duration: str = Field("", description="Duration text (e.g., '15 mins')") + duration_seconds: int | None = Field(None, description="Duration in seconds") + status: str = Field("", description="Element status (OK, NOT_FOUND, etc.)") + + +class DistanceMatrixData(BaseModel): + """Output data for google_maps_distance_matrix tool.""" + + origins: list[str] = Field(..., description="Origin addresses used") + destinations: list[str] = Field(..., description="Destination addresses used") + mode: str = Field("driving", description="Travel mode used") + elements: list[DistanceMatrixElement] = Field( + ..., description="Distance/duration results for each origin-destination pair" + ) + + +# ============================================================================= +# Response Wrappers +# ============================================================================= + + +class GeocodeResponse(ToolResponse[GeocodeData]): + """Response schema for google_maps_geocode tool.""" + + pass + + +class ReverseGeocodeResponse(ToolResponse[ReverseGeocodeData]): + """Response schema for google_maps_reverse_geocode tool.""" + + pass + + +class DirectionsResponse(ToolResponse[DirectionsData]): + """Response schema for google_maps_directions tool.""" + + pass + + +class PlacesSearchResponse(ToolResponse[PlacesSearchData]): + """Response schema for google_maps_search_places tool.""" + + pass + + +class DistanceMatrixResponse(ToolResponse[DistanceMatrixData]): + """Response schema for google_maps_distance_matrix tool.""" + + pass diff --git a/src/tools/google/schemas/sheets.py b/src/tools/google/schemas/sheets.py new file mode 100644 index 0000000..fd86eef --- /dev/null +++ b/src/tools/google/schemas/sheets.py @@ -0,0 +1,142 @@ +"""Pydantic output schemas for Google Sheets tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class SpreadsheetInfo(BaseModel): + """Basic spreadsheet information.""" + + id: str = Field(..., description="Spreadsheet ID") + name: str = Field(..., description="Spreadsheet name") + modified: str = Field("", description="Last modified date") + web_link: str = Field("", description="Web view link") + + +class SheetInfo(BaseModel): + """Information about a sheet within a spreadsheet.""" + + id: int = Field(..., description="Sheet ID") + title: str = Field(..., description="Sheet title") + index: int = Field(..., description="Sheet index") + row_count: int = Field(0, description="Number of rows") + column_count: int = Field(0, description="Number of columns") + + +class SheetsListData(BaseModel): + """Output data for google_sheets_list_spreadsheets tool.""" + + spreadsheets: list[SpreadsheetInfo] = Field(..., description="List of spreadsheets") + total: int = Field(..., description="Total number of spreadsheets") + + +class SheetsGetInfoData(BaseModel): + """Output data for google_sheets_get_info tool.""" + + id: str = Field(..., description="Spreadsheet ID") + title: str = Field(..., description="Spreadsheet title") + locale: str = Field("", description="Spreadsheet locale") + sheets: list[SheetInfo] = Field(..., description="List of sheets") + web_link: str = Field("", description="Web view link") + + +class SheetsReadValuesData(BaseModel): + """Output data for google_sheets_read_values tool.""" + + range: str = Field(..., description="Range that was read") + rows: list[list[Any]] = Field(..., description="2D array of cell values") + row_count: int = Field(..., description="Number of rows") + column_count: int = Field(..., description="Number of columns") + + +class SheetsWriteValuesData(BaseModel): + """Output data for google_sheets_write_values tool.""" + + updated_range: str = Field(..., description="Range that was updated") + updated_rows: int = Field(0, description="Number of rows updated") + updated_columns: int = Field(0, description="Number of columns updated") + updated_cells: int = Field(0, description="Total cells updated") + + +class SheetsAppendValuesData(BaseModel): + """Output data for google_sheets_append_values tool.""" + + updated_range: str = Field(..., description="Range that was appended to") + updated_rows: int = Field(0, description="Number of rows added") + updated_cells: int = Field(0, description="Total cells added") + + +class SheetsCreateData(BaseModel): + """Output data for google_sheets_create_spreadsheet tool.""" + + id: str = Field(..., description="Created spreadsheet ID") + title: str = Field(..., description="Spreadsheet title") + sheets: list[str] = Field(..., description="Sheet names") + web_link: str = Field("", description="Web view link") + + +class SheetsAddSheetData(BaseModel): + """Output data for google_sheets_add_sheet tool.""" + + sheet_id: int | None = Field(None, description="New sheet ID") + title: str | None = Field(None, description="Sheet title") + spreadsheet_id: str = Field(..., description="Parent spreadsheet ID") + + +class SheetsClearValuesData(BaseModel): + """Output data for google_sheets_clear_values tool.""" + + cleared_range: str = Field(..., description="Range that was cleared") + spreadsheet_id: str = Field(..., description="Spreadsheet ID") + + +# Sheets Responses +class SheetsListResponse(ToolResponse[SheetsListData]): + """Response for google_sheets_list_spreadsheets tool.""" + + pass + + +class SheetsGetInfoResponse(ToolResponse[SheetsGetInfoData]): + """Response for google_sheets_get_info tool.""" + + pass + + +class SheetsReadValuesResponse(ToolResponse[SheetsReadValuesData]): + """Response for google_sheets_read_values tool.""" + + pass + + +class SheetsWriteValuesResponse(ToolResponse[SheetsWriteValuesData]): + """Response for google_sheets_write_values tool.""" + + pass + + +class SheetsAppendValuesResponse(ToolResponse[SheetsAppendValuesData]): + """Response for google_sheets_append_values tool.""" + + pass + + +class SheetsCreateResponse(ToolResponse[SheetsCreateData]): + """Response for google_sheets_create_spreadsheet tool.""" + + pass + + +class SheetsAddSheetResponse(ToolResponse[SheetsAddSheetData]): + """Response for google_sheets_add_sheet tool.""" + + pass + + +class SheetsClearValuesResponse(ToolResponse[SheetsClearValuesData]): + """Response for google_sheets_clear_values tool.""" + + pass diff --git a/src/tools/google/schemas/slides.py b/src/tools/google/schemas/slides.py new file mode 100644 index 0000000..4714f77 --- /dev/null +++ b/src/tools/google/schemas/slides.py @@ -0,0 +1,121 @@ +"""Pydantic output schemas for Google Slides tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class PresentationInfo(BaseModel): + """Basic presentation information.""" + + id: str = Field(..., description="Presentation ID") + name: str = Field(..., description="Presentation name") + modified: str = Field("", description="Last modified date") + web_link: str = Field("", description="Web view link") + + +class SlideElement(BaseModel): + """Element in a slide.""" + + type: str = Field(..., description="Element type") + content: str = Field("", description="Element content") + + +class SlideInfo(BaseModel): + """Information about a slide.""" + + id: str = Field(..., description="Slide ID") + elements: list[SlideElement] = Field( + default_factory=list, description="Slide elements" + ) + + +class PresentationDetailed(BaseModel): + """Detailed presentation information.""" + + id: str = Field(..., description="Presentation ID") + title: str = Field("", description="Presentation title") + slide_count: int = Field(0, description="Number of slides") + slides: list[SlideInfo] = Field(default_factory=list, description="Slide info") + width: float = Field(0, description="Page width") + height: float = Field(0, description="Page height") + + +class PresentationCreated(BaseModel): + """Information about a created presentation.""" + + id: str = Field(..., description="Presentation ID") + title: str = Field("", description="Presentation title") + slide_count: int = Field(0, description="Number of slides") + web_link: str = Field("", description="Web link") + + +class SlideAdded(BaseModel): + """Information about an added slide.""" + + slide_id: str | None = Field(None, description="New slide ID") + presentation_id: str = Field(..., description="Presentation ID") + layout: str = Field("", description="Slide layout") + + +class TextAdded(BaseModel): + """Information about added text.""" + + shape_id: str = Field(..., description="Shape ID") + slide_id: str = Field(..., description="Slide ID") + text: str = Field("", description="Text content") + + +class SlideThumbnail(BaseModel): + """Slide thumbnail information.""" + + slide_id: str = Field(..., description="Slide ID") + content_url: str = Field("", description="Thumbnail URL") + width: int = Field(0, description="Thumbnail width") + height: int = Field(0, description="Thumbnail height") + + +class SlidesListData(BaseModel): + """Output data for google_slides_list_presentations tool.""" + + presentations: list[PresentationInfo] = Field( + ..., description="List of presentations" + ) + total: int = Field(..., description="Total number of presentations") + + +# Slides Responses +class SlidesListPresentationsResponse(ToolResponse[SlidesListData]): + """Response for google_slides_list_presentations tool.""" + + pass + + +class SlidesGetPresentationResponse(ToolResponse[PresentationDetailed]): + """Response for google_slides_get_presentation tool.""" + + pass + + +class SlidesCreatePresentationResponse(ToolResponse[PresentationCreated]): + """Response for google_slides_create_presentation tool.""" + + pass + + +class SlidesAddSlideResponse(ToolResponse[SlideAdded]): + """Response for google_slides_add_slide tool.""" + + pass + + +class SlidesAddTextResponse(ToolResponse[TextAdded]): + """Response for google_slides_add_text tool.""" + + pass + + +class SlidesGetThumbnailResponse(ToolResponse[SlideThumbnail]): + """Response for google_slides_get_thumbnail tool.""" + + pass diff --git a/src/tools/google/schemas/tasks.py b/src/tools/google/schemas/tasks.py new file mode 100644 index 0000000..387dfd6 --- /dev/null +++ b/src/tools/google/schemas/tasks.py @@ -0,0 +1,157 @@ +"""Pydantic output schemas for Google Tasks tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + + +class TaskListInfo(BaseModel): + """Basic task list information.""" + + id: str = Field(..., description="Task list ID") + title: str = Field("", description="Task list title") + updated: str = Field("", description="Last updated time") + + +class TaskInfo(BaseModel): + """Information about a task.""" + + id: str = Field(..., description="Task ID") + title: str = Field("", description="Task title") + notes: str = Field("", description="Task notes") + status: str = Field("", description="Task status") + due: str = Field("", description="Due date") + completed: str = Field("", description="Completion time") + parent: str = Field("", description="Parent task ID") + position: str = Field("", description="Task position") + + +class TaskDetailed(BaseModel): + """Detailed task information.""" + + id: str = Field(..., description="Task ID") + title: str = Field("", description="Task title") + notes: str = Field("", description="Task notes") + status: str = Field("", description="Task status") + due: str = Field("", description="Due date") + completed: str = Field("", description="Completion time") + parent: str = Field("", description="Parent task ID") + position: str = Field("", description="Task position") + links: list[Any] = Field(default_factory=list, description="Task links") + + +class TaskCreated(BaseModel): + """Information about a created task.""" + + id: str = Field(..., description="Task ID") + title: str = Field("", description="Task title") + notes: str = Field("", description="Task notes") + status: str = Field("", description="Task status") + due: str = Field("", description="Due date") + + +class TaskUpdated(BaseModel): + """Information about an updated task.""" + + id: str = Field(..., description="Task ID") + title: str = Field("", description="Task title") + notes: str = Field("", description="Task notes") + status: str = Field("", description="Task status") + due: str = Field("", description="Due date") + completed: str = Field("", description="Completion time") + + +class TaskDeleted(BaseModel): + """Information about a deleted task.""" + + deleted_task_id: str = Field(..., description="Deleted task ID") + + +class TaskListDeleted(BaseModel): + """Information about a deleted task list.""" + + deleted_task_list_id: str = Field(..., description="Deleted task list ID") + + +class TasksClearedCompleted(BaseModel): + """Information about cleared completed tasks.""" + + cleared: bool = Field(..., description="Whether clearing succeeded") + task_list_id: str = Field(..., description="Task list ID") + + +class TasksListTaskListsData(BaseModel): + """Output data for google_tasks_list_task_lists tool.""" + + task_lists: list[TaskListInfo] = Field(..., description="List of task lists") + total: int = Field(..., description="Total number of task lists") + + +class TasksListTasksData(BaseModel): + """Output data for google_tasks_list_tasks tool.""" + + tasks: list[TaskInfo] = Field(..., description="List of tasks") + total: int = Field(..., description="Total number of tasks") + + +# Tasks Responses +class TasksListTaskListsResponse(ToolResponse[TasksListTaskListsData]): + """Response for google_tasks_list_task_lists tool.""" + + pass + + +class TasksGetTaskListResponse(ToolResponse[TaskListInfo]): + """Response for google_tasks_get_task_list tool.""" + + pass + + +class TasksCreateTaskListResponse(ToolResponse[TaskListInfo]): + """Response for google_tasks_create_task_list tool.""" + + pass + + +class TasksDeleteTaskListResponse(ToolResponse[TaskListDeleted]): + """Response for google_tasks_delete_task_list tool.""" + + pass + + +class TasksListTasksResponse(ToolResponse[TasksListTasksData]): + """Response for google_tasks_list_tasks tool.""" + + pass + + +class TasksGetTaskResponse(ToolResponse[TaskDetailed]): + """Response for google_tasks_get_task tool.""" + + pass + + +class TasksCreateTaskResponse(ToolResponse[TaskCreated]): + """Response for google_tasks_create_task tool.""" + + pass + + +class TasksUpdateTaskResponse(ToolResponse[TaskUpdated]): + """Response for google_tasks_update_task tool.""" + + pass + + +class TasksDeleteTaskResponse(ToolResponse[TaskDeleted]): + """Response for google_tasks_delete_task tool.""" + + pass + + +class TasksClearCompletedResponse(ToolResponse[TasksClearedCompleted]): + """Response for google_tasks_clear_completed tool.""" + + pass diff --git a/src/tools/image/SKILL.md b/src/tools/image/SKILL.md new file mode 100644 index 0000000..b1b3e16 --- /dev/null +++ b/src/tools/image/SKILL.md @@ -0,0 +1,79 @@ +--- +name: image-tools +description: Image processing tools including OCR text extraction. Use when you need to extract text from images. +--- + +# Image Tools + +Tools for processing and extracting data from images. + +## Text Extraction (OCR) + +Extract text from images using Tesseract OCR and output as markdown. + +### Basic usage + +```python +# Extract text from base64 encoded image +result = await image_extract_text(image_data="iVBORw0KGgo...") +``` + +### With data URL + +```python +# Also accepts data URL format +result = await image_extract_text( + image_data="data:image/png;base64,iVBORw0KGgo..." +) +``` + +### With language + +```python +# Extract Chinese text +result = await image_extract_text( + image_data="...", + language="chi_sim" +) + +# Extract Japanese text +result = await image_extract_text( + image_data="...", + language="jpn" +) +``` + +### Response format + +```json +{ + "success": true, + "data": { + "markdown": "Extracted text content\n\nFormatted as markdown" + } +} +``` + +## Supported Languages + +Common language codes: +- `eng` - English (default) +- `chi_sim` - Chinese Simplified +- `chi_tra` - Chinese Traditional +- `jpn` - Japanese +- `kor` - Korean +- `fra` - French +- `deu` - German +- `spa` - Spanish + +## Requirements + +- Tesseract OCR must be installed on the system +- Additional language packs may need to be installed for non-English text + +## When to use + +- Extract text from screenshots +- Digitize scanned documents +- Process images containing text for analysis +- Convert image-based content to searchable text diff --git a/src/tools/image/__init__.py b/src/tools/image/__init__.py new file mode 100644 index 0000000..22274a1 --- /dev/null +++ b/src/tools/image/__init__.py @@ -0,0 +1 @@ +"""Image processing tools.""" diff --git a/src/tools/image/ocr.py b/src/tools/image/ocr.py new file mode 100644 index 0000000..63b5975 --- /dev/null +++ b/src/tools/image/ocr.py @@ -0,0 +1,109 @@ +"""Image text extraction (OCR) tool.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytesseract +from PIL import Image + +from src.humcp.decorator import tool +from src.humcp.storage_path import resolve_path +from src.tools.image.schemas import ImageExtractTextData, ImageExtractTextResponse + +logger = logging.getLogger("humcp.tools.image.ocr") + + +def _text_to_markdown(text: str) -> str: + """Convert extracted text to markdown format. + + Args: + text: Raw extracted text. + + Returns: + Markdown formatted text. + """ + lines = text.strip().split("\n") + markdown_lines = [] + + for line in lines: + line = line.strip() + if not line: + markdown_lines.append("") + continue + + # Preserve the line as-is in markdown + markdown_lines.append(line) + + return "\n".join(markdown_lines) + + +@tool() +async def image_extract_text( + file_path: str, + language: str = "eng", +) -> ImageExtractTextResponse: + """Extract text from an image using OCR and output as markdown. + + Uses Tesseract OCR to extract text from images. Supports multiple languages. + Supports both local file paths and storage URLs (minio://bucket/path). + + Args: + file_path: Path to the image file (local path or minio:// URL). + language: Language code for OCR (default: "eng" for English). + Common codes: eng, chi_sim (Chinese Simplified), chi_tra (Chinese Traditional), + jpn (Japanese), kor (Korean), fra (French), deu (German), spa (Spanish). + + Returns: + Success status with extracted text in markdown format. + + Example: + # Extract text from local image file + result = await image_extract_text(file_path="/path/to/image.png") + + # Extract text from storage file + result = await image_extract_text(file_path="minio://bucket/path/to/image.png") + + # Extract Chinese text + result = await image_extract_text(file_path="/path/to/image.png", language="chi_sim") + """ + try: + # Use resolve_path to handle both local and minio:// paths + async with resolve_path(file_path) as local_path: + path = Path(local_path) + if not path.exists(): + return ImageExtractTextResponse( + success=False, error=f"File not found: {file_path}" + ) + + # Open image from file + image = Image.open(path) + + # Extract text using Tesseract + raw_text = pytesseract.image_to_string(image, lang=language) + + if not raw_text.strip(): + return ImageExtractTextResponse( + success=True, + data=ImageExtractTextData( + markdown="", + message="No text found in image", + ), + ) + + # Convert to markdown format + markdown_text = _text_to_markdown(raw_text) + + logger.info("Extracted %d characters from image", len(markdown_text)) + + return ImageExtractTextResponse( + success=True, + data=ImageExtractTextData( + markdown=markdown_text, + ), + ) + + except Exception as e: + logger.exception("Failed to extract text from image") + return ImageExtractTextResponse(success=False, error=str(e)) diff --git a/src/tools/image/schemas.py b/src/tools/image/schemas.py new file mode 100644 index 0000000..1b2e09e --- /dev/null +++ b/src/tools/image/schemas.py @@ -0,0 +1,24 @@ +"""Pydantic output schemas for image processing tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# OCR Tool Schemas +# ============================================================================= + + +class ImageExtractTextData(BaseModel): + """Output data for image_extract_text tool.""" + + markdown: str = Field(..., description="Extracted text in markdown format") + message: str | None = Field( + default=None, description="Additional message (e.g., 'No text found in image')" + ) + + +class ImageExtractTextResponse(ToolResponse[ImageExtractTextData]): + """Response schema for image_extract_text tool.""" + + pass diff --git a/src/tools/llm/SKILL.md b/src/tools/llm/SKILL.md new file mode 100644 index 0000000..0a585b3 --- /dev/null +++ b/src/tools/llm/SKILL.md @@ -0,0 +1,260 @@ +--- +name: llm +description: Generate text, call tools, and get structured JSON output using LLM providers (Claude, OpenAI, Gemini, Ollama). Use when the user needs to call an LLM API for text generation, function calling, or structured data extraction. +--- + +# LLM Tools + +Tools for generating text, calling functions, and extracting structured data using LLM APIs. + +## Requirements + +Set environment variables for the providers you want to use: +- `ANTHROPIC_API_KEY`: For Claude tools +- `OPENAI_API_KEY`: For OpenAI tools +- `GOOGLE_API_KEY`: For Gemini tools +- `OLLAMA_HOST`: For Ollama (optional, defaults to `http://localhost:11434`) + +## Basic Chat + +```python +result = await claude_chat(prompt="Explain quantum computing") +result = await openai_chat(prompt="Explain quantum computing") +result = await gemini_chat(prompt="Explain quantum computing") +result = await ollama_chat(prompt="Explain quantum computing") +``` + +## Tool / Function Calling + +### Claude + +```python +result = await claude_chat( + prompt="What's the weather in San Francisco?", + tools=[{ + "name": "get_weather", + "description": "Get current weather for a location", + "input_schema": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + }], + tool_choice={"type": "auto"} +) +# result["data"]["tool_calls"] = [{"id": "...", "name": "get_weather", "input": {"location": "San Francisco"}}] +``` + +### OpenAI (Responses API) + +Supports both custom function tools and built-in tools (`web_search`, `file_search`, `code_interpreter`). + +```python +# Custom function tool +result = await openai_chat( + prompt="What's the weather in San Francisco?", + tools=[{ + "type": "function", + "name": "get_weather", + "description": "Get current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + }], + tool_choice="auto" +) +# result["data"]["tool_calls"] = [{"id": "...", "name": "get_weather", "arguments": {"location": "San Francisco"}}] + +# Built-in web search +result = await openai_chat( + prompt="What happened in the news today?", + tools=[{"type": "web_search"}] +) +``` + +### Gemini + +```python +result = await gemini_chat( + prompt="What's the weather in San Francisco?", + tools=[{ + "name": "get_weather", + "description": "Get current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + }], + tool_config={"function_calling_config": {"mode": "AUTO"}} +) +# result["data"]["tool_calls"] = [{"name": "get_weather", "args": {"location": "San Francisco"}}] +``` + +### Ollama + +```python +result = await ollama_chat( + prompt="What's the weather in San Francisco?", + tools=[{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + }] +) +# result["data"]["tool_calls"] = [{"name": "get_weather", "arguments": {"location": "San Francisco"}}] +``` + +## Structured Output (JSON Schema) + +### Claude + +```python +result = await claude_chat( + prompt="Extract: John Smith, john@example.com, Enterprise plan", + response_format={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + "plan": {"type": "string"} + }, + "required": ["name", "email", "plan"] + } +) +``` + +### OpenAI + +```python +# JSON mode (valid JSON, no schema enforcement) +result = await openai_chat( + prompt="List 3 colors as JSON", + system_prompt="Respond in JSON format.", + response_format={"type": "json_object"} +) + +# Structured output (schema-enforced) +result = await openai_chat( + prompt="Extract: John Smith, john@example.com, Enterprise plan", + response_format={ + "type": "json_schema", + "name": "contact_info", + "strict": True, + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + "plan": {"type": "string"} + }, + "required": ["name", "email", "plan"], + "additionalProperties": False + } + } +) +``` + +### Gemini + +```python +result = await gemini_chat( + prompt="Extract: John Smith, john@example.com, Enterprise plan", + response_format={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + "plan": {"type": "string"} + }, + "required": ["name", "email", "plan"] + } +) +``` + +### Ollama + +```python +# JSON mode +result = await ollama_chat( + prompt="List 3 colors as JSON", + response_format="json" +) + +# Schema-enforced structured output +result = await ollama_chat( + prompt="Extract: John Smith, john@example.com, Enterprise plan", + response_format={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + "plan": {"type": "string"} + }, + "required": ["name", "email", "plan"] + } +) +``` + +## Response Format + +All tools return a standardized response: + +```json +{ + "success": true, + "data": { + "model": "gpt-4o", + "content": "Generated text...", + "usage": {"input_tokens": 25, "output_tokens": 150}, + "tool_calls": [{"id": "...", "name": "fn", "arguments": {...}}] + } +} +``` + +Provider-specific fields: +- **Claude**: `stop_reason` (`"end_turn"`, `"tool_use"`, `"max_tokens"`) +- **OpenAI**: `id` (response ID for chaining), `status` (`"completed"`, `"failed"`) +- **Gemini**: `finish_reason` (`"STOP"`, `"MAX_TOKENS"`) +- **Ollama**: `done_reason` (`"stop"`, etc.) + +## Parameters + +| Parameter | Type | Claude | OpenAI | Gemini | Ollama | +|-----------|------|--------|--------|--------|--------| +| prompt | str | Yes | Yes | Yes | Yes | +| system_prompt | str | Yes | Yes | Yes | Yes | +| model | str | Yes | Yes | Yes | Yes | +| temperature | float | 0-1 | 0-2 | 0-2 | Yes | +| max_tokens | int | Yes | Yes | Yes | Yes | +| top_p | float | Yes | Yes | Yes | Yes | +| top_k | int | Yes | - | Yes | Yes | +| stop/stop_sequences | list[str] | Yes | - | Yes | Yes | +| frequency_penalty | float | - | - | Yes | Yes | +| presence_penalty | float | - | - | Yes | Yes | +| seed | int | - | - | - | Yes | +| tools | list[dict] | Yes | Yes | Yes | Yes | +| tool_choice/tool_config | dict/str | Yes | Yes | Yes | - | +| response_format | dict/str | Yes | Yes | Yes | Yes | +| store | bool | - | Yes | - | - | +| host | str | - | - | - | Yes | +| extra | dict | Yes | Yes | Yes | Yes | + +The `extra` parameter accepts a dict of additional provider-specific kwargs passed directly to the underlying API call. diff --git a/src/tools/llm/__init__.py b/src/tools/llm/__init__.py new file mode 100644 index 0000000..5fb75df --- /dev/null +++ b/src/tools/llm/__init__.py @@ -0,0 +1,4 @@ +"""LLM tools. + +All tools use the @tool decorator for automatic registration. +""" diff --git a/src/tools/llm/claude.py b/src/tools/llm/claude.py new file mode 100644 index 0000000..a441916 --- /dev/null +++ b/src/tools/llm/claude.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool + +try: + from anthropic import AsyncAnthropic +except ImportError as err: + raise ImportError( + "anthropic is required for Claude tools. Install with: pip install anthropic" + ) from err + +logger = logging.getLogger("humcp.tools.llm.claude") + + +@tool() +async def claude_chat( + prompt: str, + system_prompt: str = "", + model: str = "claude-sonnet-4-20250514", + temperature: float = 1.0, + max_tokens: int = 1024, + top_p: float | None = None, + top_k: int | None = None, + stop_sequences: list[str] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict: + """ + Generate a response using Anthropic's Claude API. + + Supports text generation, tool use, and structured JSON output. + + Args: + prompt: The user message to send to Claude. + system_prompt: Optional system prompt to guide Claude's behavior. + model: The Claude model to use. + temperature: Sampling temperature (0.0 to 1.0). + max_tokens: Maximum number of tokens to generate. + top_p: Nucleus sampling threshold (0.0 to 1.0). Use instead of temperature. + top_k: Sample from top K tokens at each step. + stop_sequences: Custom text sequences that cause the model to stop generating. + tools: List of tool definitions. Each dict should have "name", "description", + and "input_schema" (JSON Schema object). Optionally set "strict": true + for guaranteed schema-compliant inputs. + tool_choice: Controls tool use: {"type": "auto"}, {"type": "any"}, + {"type": "tool", "name": "..."}, or {"type": "none"}. + response_format: JSON Schema for structured output. Passed as + output_config.format with type "json_schema". + Example: {"type": "object", "properties": {...}, "required": [...]}. + extra: Additional keyword arguments passed directly to + client.messages.create(). Use for any API parameters not + explicitly listed (e.g. metadata, thinking, service_tier). + + Returns: + Success flag with model response data or error message. + """ + try: + api_key = await resolve_credential("ANTHROPIC_API_KEY") + if not api_key: + return { + "success": False, + "error": "ANTHROPIC_API_KEY is not set.", + } + + logger.info("Claude chat start model=%s", model) + client = AsyncAnthropic(api_key=api_key) + + kwargs: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": [{"role": "user", "content": prompt}], + } + if system_prompt: + kwargs["system"] = system_prompt + if top_p is not None: + kwargs["top_p"] = top_p + if top_k is not None: + kwargs["top_k"] = top_k + if stop_sequences: + kwargs["stop_sequences"] = stop_sequences + if tools: + kwargs["tools"] = tools + if tool_choice: + kwargs["tool_choice"] = tool_choice + if response_format: + kwargs["output_config"] = { + "format": {"type": "json_schema", "schema": response_format} + } + if extra: + kwargs.update(extra) + + response = await client.messages.create(**kwargs) + + content = "".join( + block.text for block in response.content if block.type == "text" + ) + + tool_calls = [ + { + "id": block.id, + "name": block.name, + "input": block.input, + } + for block in response.content + if block.type == "tool_use" + ] + + data: dict[str, Any] = { + "model": response.model, + "content": content, + "stop_reason": response.stop_reason, + "usage": { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + }, + } + if tool_calls: + data["tool_calls"] = tool_calls + + return {"success": True, "data": data} + except Exception as e: + logger.exception("Claude chat failed") + return {"success": False, "error": f"Claude chat failed: {e}"} diff --git a/src/tools/llm/gemini.py b/src/tools/llm/gemini.py new file mode 100644 index 0000000..d097c5f --- /dev/null +++ b/src/tools/llm/gemini.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool + +try: + from google import genai + from google.genai import types +except ImportError as err: + raise ImportError( + "google-genai is required for Gemini tools. Install with: pip install google-genai" + ) from err + +logger = logging.getLogger("humcp.tools.llm.gemini") + + +@tool() +async def gemini_chat( + prompt: str, + system_prompt: str = "", + model: str = "gemini-2.5-flash", + temperature: float = 1.0, + max_tokens: int = 1024, + top_p: float | None = None, + top_k: int | None = None, + stop_sequences: list[str] | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_config: dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict: + """ + Generate a response using Google's Gemini API. + + Supports text generation, function calling, and structured JSON output. + + Args: + prompt: The user message to send to Gemini. + system_prompt: Optional system instruction to guide Gemini's behavior. + model: The Gemini model to use. + temperature: Sampling temperature (0.0 to 2.0). + max_tokens: Maximum number of tokens to generate. + top_p: Nucleus sampling threshold (0.0 to 1.0). + top_k: Sample from top K tokens at each step. + stop_sequences: Strings that cause the model to stop generating. + frequency_penalty: Penalize tokens by frequency (-2.0 to 2.0). + presence_penalty: Penalize tokens already present (-2.0 to 2.0). + tools: List of function declarations. Each dict should have "name", + "description", and "parameters" (JSON Schema object). These are + wrapped into a Tool with function_declarations. + tool_config: Controls function calling behavior. Example: + {"function_calling_config": {"mode": "ANY", + "allowed_function_names": ["..."]}}. + Modes: "AUTO", "ANY", "NONE", "VALIDATED". + response_format: JSON Schema for structured output. When provided, + sets response_mime_type to "application/json" and passes the + schema via response_json_schema. + extra: Additional keyword arguments passed directly to + GenerateContentConfig. Use for any config parameters not + explicitly listed (e.g. safety_settings, thinking_config, + candidate_count, seed). + + Returns: + Success flag with model response data or error message. + """ + try: + api_key = await resolve_credential("GOOGLE_API_KEY") + if not api_key: + return { + "success": False, + "error": "GOOGLE_API_KEY is not set.", + } + + logger.info("Gemini chat start model=%s", model) + client = genai.Client(api_key=api_key) + + config_kwargs: dict[str, Any] = { + "temperature": temperature, + "max_output_tokens": max_tokens, + } + if system_prompt: + config_kwargs["system_instruction"] = system_prompt + if top_p is not None: + config_kwargs["top_p"] = top_p + if top_k is not None: + config_kwargs["top_k"] = top_k + if stop_sequences: + config_kwargs["stop_sequences"] = stop_sequences + if frequency_penalty is not None: + config_kwargs["frequency_penalty"] = frequency_penalty + if presence_penalty is not None: + config_kwargs["presence_penalty"] = presence_penalty + if tools: + config_kwargs["tools"] = [types.Tool(function_declarations=tools)] # type: ignore[arg-type] + if tool_config: + config_kwargs["tool_config"] = tool_config + if response_format: + config_kwargs["response_mime_type"] = "application/json" + config_kwargs["response_json_schema"] = response_format + if extra: + config_kwargs.update(extra) + + config = types.GenerateContentConfig(**config_kwargs) + + response = await client.aio.models.generate_content( + model=model, + contents=prompt, + config=config, + ) + + content = response.text or "" + + tool_calls = None + if response.function_calls: + tool_calls = [ + { + "name": fc.name, + "args": dict(fc.args) if fc.args else {}, + **({"id": fc.id} if fc.id else {}), + } + for fc in response.function_calls + ] + + finish_reason = None + if response.candidates and response.candidates[0].finish_reason: + finish_reason = response.candidates[0].finish_reason.name + + usage_meta = response.usage_metadata + usage: dict[str, Any] = {} + if usage_meta: + usage = { + "prompt_tokens": usage_meta.prompt_token_count, + "completion_tokens": usage_meta.candidates_token_count, + "total_tokens": usage_meta.total_token_count, + } + + data: dict[str, Any] = { + "model": model, + "content": content, + "finish_reason": finish_reason, + "usage": usage, + } + if tool_calls: + data["tool_calls"] = tool_calls + + return {"success": True, "data": data} + except Exception as e: + logger.exception("Gemini chat failed") + return {"success": False, "error": f"Gemini chat failed: {e}"} diff --git a/src/tools/llm/ollama.py b/src/tools/llm/ollama.py new file mode 100644 index 0000000..a49ae35 --- /dev/null +++ b/src/tools/llm/ollama.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.decorator import tool + +try: + from ollama import AsyncClient +except ImportError as err: + raise ImportError( + "ollama is required for Ollama tools. Install with: pip install ollama" + ) from err + +logger = logging.getLogger("humcp.tools.llm.ollama") + + +@tool() +async def ollama_chat( + prompt: str, + system_prompt: str = "", + model: str = "llama3.2", + temperature: float | None = None, + max_tokens: int | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | None = None, + seed: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + tools: list[dict[str, Any]] | None = None, + response_format: str | dict[str, Any] | None = None, + host: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict: + """ + Generate a response using a local or remote Ollama server. + + Supports text generation, tool calling, and structured JSON output. + + Args: + prompt: The user message to send to the model. + system_prompt: Optional system prompt to guide the model's behavior. + model: The Ollama model to use (e.g. "llama3.2", "gemma3", "qwen3"). + temperature: Sampling temperature. Higher = more creative. + max_tokens: Maximum number of tokens to generate (maps to num_predict). + top_p: Nucleus sampling threshold (0.0 to 1.0). + top_k: Sample from top K tokens at each step. + stop: Sequences that cause the model to stop generating. + seed: Random seed for reproducible output. + frequency_penalty: Penalize tokens by frequency of appearance. + presence_penalty: Penalize tokens that have already appeared. + tools: List of tool definitions. Each dict should have "type": "function" + and a "function" key with "name", "description", "parameters" + (JSON Schema object). + response_format: Constrain output format. Use "json" for JSON mode, or + a JSON Schema dict for schema-enforced structured output. + host: Ollama server URL. Defaults to OLLAMA_HOST env var or + http://localhost:11434. + extra: Additional keyword arguments passed directly to + client.chat(). Use for any parameters not explicitly listed + (e.g. think, keep_alive, logprobs). + + Returns: + Success flag with model response data or error message. + """ + try: + resolved_host = host or os.getenv("OLLAMA_HOST") + client_kwargs: dict[str, Any] = {} + if resolved_host: + client_kwargs["host"] = resolved_host + + logger.info("Ollama chat start model=%s", model) + client = AsyncClient(**client_kwargs) + + messages: list[dict[str, Any]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + options: dict[str, Any] = {} + if temperature is not None: + options["temperature"] = temperature + if max_tokens is not None: + options["num_predict"] = max_tokens + if top_p is not None: + options["top_p"] = top_p + if top_k is not None: + options["top_k"] = top_k + if stop: + options["stop"] = stop + if seed is not None: + options["seed"] = seed + if frequency_penalty is not None: + options["frequency_penalty"] = frequency_penalty + if presence_penalty is not None: + options["presence_penalty"] = presence_penalty + + kwargs: dict[str, Any] = { + "model": model, + "messages": messages, + } + if options: + kwargs["options"] = options + if tools: + kwargs["tools"] = tools + if response_format is not None: + kwargs["format"] = response_format + if extra: + kwargs.update(extra) + + response = await client.chat(**kwargs) + + content = response.message.content or "" + + tool_calls = None + if response.message.tool_calls: + tool_calls = [ + { + "name": tc.function.name, + "arguments": dict(tc.function.arguments), + } + for tc in response.message.tool_calls + ] + + usage: dict[str, Any] = {} + if response.prompt_eval_count is not None: + usage["prompt_tokens"] = response.prompt_eval_count + if response.eval_count is not None: + usage["completion_tokens"] = response.eval_count + if response.prompt_eval_count is not None: + usage["total_tokens"] = response.prompt_eval_count + response.eval_count + + data: dict[str, Any] = { + "model": response.model, + "content": content, + "done_reason": response.done_reason, + "usage": usage, + } + if tool_calls: + data["tool_calls"] = tool_calls + + return {"success": True, "data": data} + except Exception as e: + logger.exception("Ollama chat failed") + return {"success": False, "error": f"Ollama chat failed: {e}"} diff --git a/src/tools/llm/openai.py b/src/tools/llm/openai.py new file mode 100644 index 0000000..7e9ecce --- /dev/null +++ b/src/tools/llm/openai.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool + +try: + from openai import AsyncOpenAI +except ImportError as err: + raise ImportError( + "openai is required for OpenAI tools. Install with: pip install openai" + ) from err + +logger = logging.getLogger("humcp.tools.llm.openai") + + +@tool() +async def openai_chat( + prompt: str, + system_prompt: str = "", + model: str = "gpt-4o", + temperature: float = 1.0, + max_tokens: int = 1024, + top_p: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + store: bool | None = None, + extra: dict[str, Any] | None = None, +) -> dict: + """ + Generate a response using OpenAI's Responses API. + + Supports text generation, tool calling (including built-in web_search, + file_search, code_interpreter), and structured JSON output. + + Args: + prompt: The user message to send to the model. + system_prompt: Optional system instructions to guide the model's behavior. + model: The OpenAI model to use. + temperature: Sampling temperature (0.0 to 2.0). + max_tokens: Maximum number of output tokens to generate. + top_p: Nucleus sampling threshold (0.0 to 1.0). Alternative to temperature. + tools: List of tool definitions. For function tools use + {"type": "function", "name": "...", "parameters": {...}}. + Built-in tools: {"type": "web_search"}, {"type": "file_search", ...}, + {"type": "code_interpreter"}. + tool_choice: Controls tool use: "auto", "none", "required", or + {"type": "function", "name": "..."}. + response_format: Structured output config passed as text.format. + Use {"type": "json_object"} for JSON mode, or + {"type": "json_schema", "name": "...", "strict": true, + "schema": {...}} for schema-enforced output. + store: Whether to store the response server-side. Defaults to true. + extra: Additional keyword arguments passed directly to + client.responses.create(). Use for any API parameters not + explicitly listed (e.g. reasoning, previous_response_id, + include, truncation, metadata). + + Returns: + Success flag with model response data or error message. + """ + try: + api_key = await resolve_credential("OPENAI_API_KEY") + if not api_key: + return { + "success": False, + "error": "OPENAI_API_KEY is not set.", + } + + logger.info("OpenAI chat start model=%s", model) + client = AsyncOpenAI(api_key=api_key) + + kwargs: dict[str, Any] = { + "model": model, + "input": prompt, + "temperature": temperature, + "max_output_tokens": max_tokens, + } + if system_prompt: + kwargs["instructions"] = system_prompt + if top_p is not None: + kwargs["top_p"] = top_p + if tools: + kwargs["tools"] = tools + if tool_choice is not None: + kwargs["tool_choice"] = tool_choice + if response_format: + kwargs["text"] = {"format": response_format} + if store is not None: + kwargs["store"] = store + if extra: + kwargs.update(extra) + + response = await client.responses.create(**kwargs) + + content = response.output_text + + tool_calls = [] + for item in response.output: + if item.type == "function_call": + tool_calls.append( + { + "id": item.call_id, + "name": item.name, + "arguments": json.loads(item.arguments), + } + ) + elif item.type == "web_search_call": + tool_calls.append( + { + "id": item.id, + "type": "web_search_call", + "status": item.status, + } + ) + + usage_data: dict[str, Any] = {} + if response.usage: + usage_data = { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + "total_tokens": response.usage.total_tokens, + } + + data: dict[str, Any] = { + "id": response.id, + "model": response.model, + "content": content, + "status": response.status, + "usage": usage_data, + } + if tool_calls: + data["tool_calls"] = tool_calls + + return {"success": True, "data": data} + except Exception as e: + logger.exception("OpenAI chat failed") + return {"success": False, "error": f"OpenAI chat failed: {e}"} diff --git a/src/tools/local/schemas.py b/src/tools/local/schemas.py new file mode 100644 index 0000000..207495e --- /dev/null +++ b/src/tools/local/schemas.py @@ -0,0 +1,329 @@ +"""Pydantic output schemas for local tools (calculator, shell, filesystem).""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Calculator Tool Schemas +# ============================================================================= + + +class BinaryOperationData(BaseModel): + """Output data for binary arithmetic operations (add, subtract, multiply, divide, etc.).""" + + operation: str = Field(..., description="Name of the operation performed") + a: float = Field(..., description="First operand") + b: float = Field(..., description="Second operand") + result: float = Field(..., description="Result of the operation") + + +class FactorialData(BaseModel): + """Output data for factorial operation.""" + + operation: str = Field("factorial", description="Name of the operation") + n: int = Field(..., description="Input number") + result: int = Field(..., description="Factorial result") + + +class IsPrimeData(BaseModel): + """Output data for is_prime check.""" + + n: int = Field(..., description="Number that was checked") + is_prime: bool = Field(..., description="Whether the number is prime") + divisible_by: int | None = Field( + None, description="First divisor found (if not prime)" + ) + + +class UnaryOperationData(BaseModel): + """Output data for unary operations (sqrt, abs).""" + + operation: str = Field(..., description="Name of the operation") + n: float = Field(..., description="Input number") + result: float = Field(..., description="Result of the operation") + + +class LogarithmData(BaseModel): + """Output data for logarithm operation.""" + + operation: str = Field( + ..., description="'ln' for natural log, 'log' for custom base" + ) + n: float = Field(..., description="Input number") + base: float | None = Field( + None, description="Logarithm base (None for natural log)" + ) + result: float = Field(..., description="Logarithm result") + + +# ============================================================================= +# Shell Tool Schemas +# ============================================================================= + + +class ShellCommandData(BaseModel): + """Output data for shell command execution.""" + + command: str = Field(..., description="Command that was executed") + return_code: int = Field(..., description="Process return code (0 = success)") + stdout: str = Field("", description="Standard output") + stderr: str = Field("", description="Standard error output") + working_directory: str = Field(..., description="Directory where command was run") + output_truncated: bool = Field(False, description="Whether output was truncated") + + +class ShellScriptData(BaseModel): + """Output data for shell script execution.""" + + script: str = Field(..., description="Script that was executed") + shell: str = Field(..., description="Shell interpreter used") + return_code: int = Field(..., description="Process return code") + stdout: str = Field("", description="Standard output") + stderr: str = Field("", description="Standard error output") + working_directory: str = Field(..., description="Directory where script was run") + + +class CommandExistsData(BaseModel): + """Output data for command existence check.""" + + command: str = Field(..., description="Command that was checked") + exists: bool = Field(..., description="Whether the command exists") + path: str | None = Field(None, description="Full path to command if found") + + +class EnvironmentVariableData(BaseModel): + """Output data for environment variable retrieval.""" + + variable_name: str = Field(..., description="Name of the environment variable") + value: str | None = Field(None, description="Value of the variable") + is_set: bool = Field(..., description="Whether the variable is set") + + +class CurrentDirectoryData(BaseModel): + """Output data for current directory retrieval.""" + + current_directory: str = Field(..., description="Current working directory path") + + +class SystemInfoData(BaseModel): + """Output data for system information.""" + + os: str = Field(..., description="Operating system name") + os_version: str = Field(..., description="Operating system version") + platform: str = Field(..., description="Platform identifier") + architecture: str = Field(..., description="Machine architecture") + processor: str = Field(..., description="Processor type") + python_version: str = Field(..., description="Python version") + hostname: str = Field(..., description="Machine hostname") + user: str | None = Field(None, description="Current user") + + +# ============================================================================= +# Filesystem Tool Schemas +# ============================================================================= + + +class WriteFileData(BaseModel): + """Output data for file write operation.""" + + message: str = Field(..., description="Success message") + file_path: str = Field(..., description="Full path to the created file") + filename: str = Field(..., description="Name of the file") + directory: str = Field(..., description="Directory containing the file") + size_bytes: int = Field(..., description="Size of written content in bytes") + + +class ReadFileData(BaseModel): + """Output data for file read operation.""" + + content: str = Field(..., description="File content") + file_path: str = Field(..., description="Full path to the file") + filename: str = Field(..., description="Name of the file") + size_bytes: int = Field(..., description="File size in bytes") + + +class FileInfo(BaseModel): + """Information about a single file.""" + + name: str = Field(..., description="File name") + path: str = Field(..., description="Full file path") + size_bytes: int = Field(..., description="File size in bytes") + extension: str | None = Field(None, description="File extension") + modified_time: float = Field(..., description="Last modification timestamp") + + +class ListFilesData(BaseModel): + """Output data for list files operation.""" + + files: list[FileInfo] = Field(default_factory=list, description="List of files") + count: int = Field(..., description="Number of files found") + directory: str = Field(..., description="Directory that was listed") + + +class DeleteFileData(BaseModel): + """Output data for file deletion.""" + + message: str = Field(..., description="Success message") + file_path: str = Field(..., description="Path of deleted file") + filename: str = Field(..., description="Name of deleted file") + + +class CreateDirectoryData(BaseModel): + """Output data for directory creation.""" + + message: str = Field(..., description="Success message") + directory: str = Field(..., description="Path of created directory") + + +class FileExistsData(BaseModel): + """Output data for file existence check.""" + + exists: bool = Field(..., description="Whether the file exists") + file_path: str = Field(..., description="Full path to the file") + filename: str = Field(..., description="Name of the file") + size_bytes: int | None = Field(None, description="File size if exists") + modified_time: float | None = Field(None, description="Modification time if exists") + + +class FileInfoDetailedData(BaseModel): + """Output data for detailed file information.""" + + name: str = Field(..., description="File name") + path: str = Field(..., description="Full file path") + size_bytes: int = Field(..., description="File size in bytes") + extension: str | None = Field(None, description="File extension") + created_time: float = Field(..., description="Creation timestamp") + modified_time: float = Field(..., description="Modification timestamp") + accessed_time: float = Field(..., description="Last access timestamp") + is_symlink: bool = Field(False, description="Whether file is a symbolic link") + + +class AppendFileData(BaseModel): + """Output data for file append operation.""" + + message: str = Field(..., description="Success message") + file_path: str = Field(..., description="Full path to the file") + appended_bytes: int = Field(..., description="Number of bytes appended") + new_size_bytes: int = Field(..., description="New total file size") + + +class CopyFileData(BaseModel): + """Output data for file copy operation.""" + + message: str = Field(..., description="Success message") + source_path: str = Field(..., description="Source file path") + destination_path: str = Field(..., description="Destination file path") + size_bytes: int = Field(..., description="Size of copied file") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +# Calculator Responses +class CalculatorResponse( + ToolResponse[ + BinaryOperationData + | FactorialData + | IsPrimeData + | UnaryOperationData + | LogarithmData + ] +): + """Generic response for calculator operations.""" + + pass + + +# Shell Responses +class ShellCommandResponse(ToolResponse[ShellCommandData]): + """Response for shell command execution.""" + + pass + + +class ShellScriptResponse(ToolResponse[ShellScriptData]): + """Response for shell script execution.""" + + pass + + +class CommandExistsResponse(ToolResponse[CommandExistsData]): + """Response for command existence check.""" + + pass + + +class EnvironmentVariableResponse(ToolResponse[EnvironmentVariableData]): + """Response for environment variable retrieval.""" + + pass + + +class CurrentDirectoryResponse(ToolResponse[CurrentDirectoryData]): + """Response for current directory retrieval.""" + + pass + + +class SystemInfoResponse(ToolResponse[SystemInfoData]): + """Response for system information.""" + + pass + + +# Filesystem Responses +class WriteFileResponse(ToolResponse[WriteFileData]): + """Response for file write operation.""" + + pass + + +class ReadFileResponse(ToolResponse[ReadFileData]): + """Response for file read operation.""" + + pass + + +class ListFilesResponse(ToolResponse[ListFilesData]): + """Response for list files operation.""" + + pass + + +class DeleteFileResponse(ToolResponse[DeleteFileData]): + """Response for file deletion.""" + + pass + + +class CreateDirectoryResponse(ToolResponse[CreateDirectoryData]): + """Response for directory creation.""" + + pass + + +class FileExistsResponse(ToolResponse[FileExistsData]): + """Response for file existence check.""" + + pass + + +class FileInfoResponse(ToolResponse[FileInfoDetailedData]): + """Response for file information.""" + + pass + + +class AppendFileResponse(ToolResponse[AppendFileData]): + """Response for file append operation.""" + + pass + + +class CopyFileResponse(ToolResponse[CopyFileData]): + """Response for file copy operation.""" + + pass diff --git a/src/tools/media/SKILL.md b/src/tools/media/SKILL.md new file mode 100644 index 0000000..1dead04 --- /dev/null +++ b/src/tools/media/SKILL.md @@ -0,0 +1,126 @@ +--- +name: media-tools +description: Media generation and manipulation tools for creating images, videos, and GIFs. Use when the user needs to generate, search for, or transform visual media content. +--- + +# Media Tools + +Tools for generating and manipulating media content including images, videos, and GIFs. + +## Available Tools + +### Image Generation +- **dalle_generate_image** - Generate images using OpenAI DALL-E +- **models_labs_generate_image** - Generate images via ModelsLab API +- **nano_banana_run** - Generate media via NanoBanana/Banana.dev API + +### Image Search +- **unsplash_search_photos** - Search Unsplash for high-quality royalty-free photos +- **unsplash_get_random_photo** - Get random photos from Unsplash +- **giphy_search** - Search for GIFs on Giphy +- **giphy_trending** - Get trending GIFs from Giphy + +### Video Generation +- **lumalab_generate_video** - Generate videos using Luma AI +- **moviepy_create_video** - Create videos from image sequences using MoviePy + +### AI Model Platforms +- **replicate_run_model** - Run any model on Replicate +- **replicate_get_prediction** - Check status of a Replicate prediction +- **fal_run_model** - Run AI models via Fal.ai + +### Image Processing +- **opencv_resize_image** - Resize images using OpenCV +- **opencv_convert_format** - Convert image format using OpenCV + +## Requirements + +Set environment variables as needed: +- `GIPHY_API_KEY` - Giphy API key +- `UNSPLASH_ACCESS_KEY` - Unsplash API access key +- `OPENAI_API_KEY` - OpenAI API key (for DALL-E) +- `REPLICATE_API_TOKEN` - Replicate API token +- `LUMAAI_API_KEY` - Luma AI API key +- `FAL_KEY` - Fal.ai API key +- `MODELS_LAB_API_KEY` - ModelsLab API key +- `BANANA_API_KEY` - Banana.dev API key + +## Examples + +### Generate an image with DALL-E + +```python +result = await dalle_generate_image( + prompt="A serene mountain landscape at sunset", + size="1024x1024", + quality="hd" +) +``` + +### Search for GIFs + +```python +result = await giphy_search( + query="happy dance", + limit=5, + rating="g" +) +``` + +### Search Unsplash photos + +```python +result = await unsplash_search_photos( + query="ocean sunset", + per_page=10 +) +``` + +### Generate a video with Luma AI + +```python +result = await lumalab_generate_video( + prompt="A timelapse of clouds moving over mountains" +) +``` + +### Resize an image + +```python +result = await opencv_resize_image( + input_path="/path/to/image.jpg", + output_path="/path/to/resized.jpg", + width=800, + height=600 +) +``` + +## Response Format + +All tools return a consistent response format: + +```json +{ + "success": true, + "data": { + "...tool-specific fields..." + } +} +``` + +On error: + +```json +{ + "success": false, + "error": "Description of what went wrong" +} +``` + +## When to Use + +- Generating images from text descriptions +- Searching for stock photos or GIFs +- Creating videos from image sequences +- Running AI media generation models +- Resizing or converting image formats diff --git a/src/tools/media/__init__.py b/src/tools/media/__init__.py new file mode 100644 index 0000000..bce8830 --- /dev/null +++ b/src/tools/media/__init__.py @@ -0,0 +1 @@ +"""Media generation and manipulation tools.""" diff --git a/src/tools/media/dalle.py b/src/tools/media/dalle.py new file mode 100644 index 0000000..cacc176 --- /dev/null +++ b/src/tools/media/dalle.py @@ -0,0 +1,125 @@ +"""DALL-E image generation tool using OpenAI API.""" + +from __future__ import annotations + +import logging +from typing import Literal + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + DalleGeneratedImage, + DalleGenerateImageData, + DalleGenerateImageResponse, +) + +logger = logging.getLogger("humcp.tools.dalle") + + +@tool() +async def dalle_generate_image( + prompt: str, + size: Literal[ + "256x256", "512x512", "1024x1024", "1792x1024", "1024x1792" + ] = "1024x1024", + quality: Literal["standard", "hd"] = "standard", + style: Literal["vivid", "natural"] = "vivid", + model: str = "dall-e-3", +) -> DalleGenerateImageResponse: + """Generate an image using OpenAI DALL-E from a text prompt. + + Args: + prompt: A text description of the desired image. + size: Image dimensions. Options: "256x256", "512x512", "1024x1024", + "1792x1024", "1024x1792". Default: "1024x1024". + quality: Image quality. "standard" or "hd". Default: "standard". + style: Image style. "vivid" for hyper-real and dramatic images, or + "natural" for more natural, less hyper-real images. Default: "vivid". + model: DALL-E model to use. "dall-e-3" or "dall-e-2". Default: "dall-e-3". + + Returns: + Generated image URL and revised prompt. + """ + try: + api_key = await resolve_credential("OPENAI_API_KEY") + if not api_key: + return DalleGenerateImageResponse( + success=False, + error="OpenAI API not configured. Set OPENAI_API_KEY environment variable.", + ) + + if not prompt.strip(): + return DalleGenerateImageResponse( + success=False, error="Prompt must not be empty." + ) + + valid_models = {"dall-e-3", "dall-e-2"} + if model not in valid_models: + return DalleGenerateImageResponse( + success=False, + error=f"Invalid model '{model}'. Choose from: {', '.join(valid_models)}", + ) + + valid_sizes = {"256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"} + if size not in valid_sizes: + return DalleGenerateImageResponse( + success=False, + error=f"Invalid size '{size}'. Choose from: {', '.join(valid_sizes)}", + ) + + try: + from openai import OpenAI + except ImportError: + return DalleGenerateImageResponse( + success=False, + error="openai package is required. Install with: pip install openai", + ) + + logger.info( + "DALL-E generating image model=%s size=%s quality=%s style=%s", + model, + size, + quality, + style, + ) + + client = OpenAI(api_key=api_key) + response = client.images.generate( + prompt=prompt, + model=model, + n=1, + quality=quality, + size=size, + style=style, + ) + + images = [] + if response.data: + for img in response.data: + if img.url: + images.append( + DalleGeneratedImage( + url=img.url, + revised_prompt=img.revised_prompt, + ) + ) + + if not images: + return DalleGenerateImageResponse( + success=False, error="No images were generated." + ) + + logger.info("DALL-E generation complete images=%d", len(images)) + + return DalleGenerateImageResponse( + success=True, + data=DalleGenerateImageData( + prompt=prompt, + images=images, + ), + ) + except Exception as e: + logger.exception("DALL-E generation failed") + return DalleGenerateImageResponse( + success=False, error=f"DALL-E generation failed: {str(e)}" + ) diff --git a/src/tools/media/fal.py b/src/tools/media/fal.py new file mode 100644 index 0000000..dd3c152 --- /dev/null +++ b/src/tools/media/fal.py @@ -0,0 +1,123 @@ +"""Fal.ai model execution tool for media generation.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + FalOutput, + FalRunData, + FalRunResponse, +) + +logger = logging.getLogger("humcp.tools.fal") + + +@tool() +async def fal_run_model( + model_id: str, + input_data: dict[str, Any] | None = None, +) -> FalRunResponse: + """Run an AI model on Fal.ai for media generation. + + Args: + model_id: The Fal model identifier (e.g., "fal-ai/hunyuan-video", + "fal-ai/flux/dev"). + input_data: Input parameters for the model as a dictionary. + Typically includes "prompt" and model-specific options. + + Returns: + Generated media URLs and their types. + """ + try: + fal_key = await resolve_credential("FAL_KEY") + if not fal_key: + return FalRunResponse( + success=False, + error="Fal API not configured. Set FAL_KEY environment variable.", + ) + + if not model_id.strip(): + return FalRunResponse(success=False, error="Model ID must not be empty.") + + try: + import fal_client + except ImportError: + return FalRunResponse( + success=False, + error="fal-client package is required. Install with: pip install fal-client", + ) + + resolved_input = input_data if input_data is not None else {} + + logger.info("Fal run model=%s", model_id) + + os.environ["FAL_KEY"] = fal_key + + seen_logs: set[str] = set() + + def on_queue_update(update: Any) -> None: + if hasattr(fal_client, "InProgress") and isinstance( + update, fal_client.InProgress + ): + if hasattr(update, "logs") and update.logs: + for log_entry in update.logs: + message = ( + log_entry.get("message", "") + if isinstance(log_entry, dict) + else str(log_entry) + ) + if message not in seen_logs: + logger.info("Fal progress: %s", message) + seen_logs.add(message) + + result = fal_client.subscribe( + model_id, + arguments=resolved_input, + with_logs=True, + on_queue_update=on_queue_update, + ) + + outputs = [] + + if isinstance(result, dict): + if "image" in result: + url = result.get("image", {}).get("url", "") + if url: + outputs.append(FalOutput(url=url, media_type="image")) + elif "images" in result: + for img in result.get("images", []): + url = img.get("url", "") if isinstance(img, dict) else "" + if url: + outputs.append(FalOutput(url=url, media_type="image")) + elif "video" in result: + url = result.get("video", {}).get("url", "") + if url: + outputs.append(FalOutput(url=url, media_type="video")) + + if not outputs: + logger.warning( + "Fal returned unrecognized result structure keys=%s", + list(result.keys()), + ) + return FalRunResponse( + success=False, + error=f"Unrecognized output format from model. Keys: {list(result.keys())}", + ) + + logger.info("Fal run complete outputs=%d", len(outputs)) + + return FalRunResponse( + success=True, + data=FalRunData( + model_id=model_id, + outputs=outputs, + ), + ) + except Exception as e: + logger.exception("Fal run failed") + return FalRunResponse(success=False, error=f"Fal run failed: {str(e)}") diff --git a/src/tools/media/giphy.py b/src/tools/media/giphy.py new file mode 100644 index 0000000..abf8741 --- /dev/null +++ b/src/tools/media/giphy.py @@ -0,0 +1,231 @@ +"""Giphy tools for searching and retrieving GIFs.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + GiphyGif, + GiphyRandomData, + GiphyRandomResponse, + GiphySearchData, + GiphySearchResponse, + GiphyTrendingData, + GiphyTrendingResponse, +) + +logger = logging.getLogger("humcp.tools.giphy") + +GIPHY_API_BASE = "https://api.giphy.com/v1/gifs" + + +def _parse_gif(gif: dict) -> GiphyGif: + """Extract relevant fields from a Giphy API gif object.""" + images = gif.get("images", {}) + original = images.get("original", {}) + return GiphyGif( + url=original.get("url", ""), + alt_text=gif.get("alt_text"), + title=gif.get("title"), + ) + + +@tool() +async def giphy_search( + query: str, + limit: int = 5, + rating: str = "g", +) -> GiphySearchResponse: + """Search for GIFs on Giphy by keyword. + + Args: + query: The search query string (e.g., "happy dance", "thumbs up"). + limit: Maximum number of GIFs to return (1-50). Default: 5. + rating: Content rating filter. One of "g", "pg", "pg-13", "r". Default: "g". + + Returns: + Search results with GIF URLs and metadata. + """ + try: + api_key = await resolve_credential("GIPHY_API_KEY") + if not api_key: + return GiphySearchResponse( + success=False, + error="Giphy API not configured. Set GIPHY_API_KEY environment variable.", + ) + + if not query.strip(): + return GiphySearchResponse(success=False, error="Query must not be empty.") + + clamped_limit = max(1, min(limit, 50)) + valid_ratings = {"g", "pg", "pg-13", "r"} + resolved_rating = rating if rating in valid_ratings else "g" + + params = { + "api_key": api_key, + "q": query, + "limit": clamped_limit, + "rating": resolved_rating, + } + + logger.info( + "Giphy search query=%s limit=%d rating=%s", + query, + clamped_limit, + resolved_rating, + ) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{GIPHY_API_BASE}/search", params=params) # type: ignore[arg-type] + response.raise_for_status() + + data = response.json() + gifs = [_parse_gif(gif) for gif in data.get("data", [])] + total_count = data.get("pagination", {}).get("total_count", 0) + + logger.info("Giphy search complete results=%d total=%d", len(gifs), total_count) + + return GiphySearchResponse( + success=True, + data=GiphySearchData( + query=query, + gifs=gifs, + total_count=total_count, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Giphy search HTTP error status=%d", e.response.status_code) + return GiphySearchResponse( + success=False, error=f"Giphy API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Giphy search failed") + return GiphySearchResponse( + success=False, error=f"Giphy search failed: {str(e)}" + ) + + +@tool() +async def giphy_trending( + limit: int = 10, +) -> GiphyTrendingResponse: + """Get trending GIFs from Giphy. + + Args: + limit: Maximum number of trending GIFs to return (1-50). Default: 10. + + Returns: + List of currently trending GIFs. + """ + try: + api_key = await resolve_credential("GIPHY_API_KEY") + if not api_key: + return GiphyTrendingResponse( + success=False, + error="Giphy API not configured. Set GIPHY_API_KEY environment variable.", + ) + + clamped_limit = max(1, min(limit, 50)) + + params = { + "api_key": api_key, + "limit": clamped_limit, + } + + logger.info("Giphy trending limit=%d", clamped_limit) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{GIPHY_API_BASE}/trending", params=params) # type: ignore[arg-type] + response.raise_for_status() + + data = response.json() + gifs = [_parse_gif(gif) for gif in data.get("data", [])] + + logger.info("Giphy trending complete results=%d", len(gifs)) + + return GiphyTrendingResponse( + success=True, + data=GiphyTrendingData(gifs=gifs), + ) + except httpx.HTTPStatusError as e: + logger.exception("Giphy trending HTTP error status=%d", e.response.status_code) + return GiphyTrendingResponse( + success=False, error=f"Giphy API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Giphy trending failed") + return GiphyTrendingResponse( + success=False, error=f"Giphy trending failed: {str(e)}" + ) + + +@tool() +async def giphy_random( + tag: str | None = None, + rating: str = "g", +) -> GiphyRandomResponse: + """Get a random GIF from Giphy, optionally filtered by tag. + + Args: + tag: Optional tag to filter the random GIF (e.g., "cats", "funny"). + rating: Content rating filter. One of "g", "pg", "pg-13", "r". Default: "g". + + Returns: + A single random GIF with URL and metadata. + """ + try: + api_key = await resolve_credential("GIPHY_API_KEY") + if not api_key: + return GiphyRandomResponse( + success=False, + error="Giphy API not configured. Set GIPHY_API_KEY environment variable.", + ) + + valid_ratings = {"g", "pg", "pg-13", "r"} + resolved_rating = rating if rating in valid_ratings else "g" + + params: dict = { + "api_key": api_key, + "rating": resolved_rating, + } + + if tag and tag.strip(): + params["tag"] = tag.strip() + + logger.info("Giphy random tag=%s rating=%s", tag, resolved_rating) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{GIPHY_API_BASE}/random", params=params) + response.raise_for_status() + + data = response.json() + gif_data = data.get("data", {}) + + if not gif_data: + return GiphyRandomResponse(success=False, error="No random GIF returned.") + + gif = _parse_gif(gif_data) + + logger.info("Giphy random complete title=%s", gif.title) + + return GiphyRandomResponse( + success=True, + data=GiphyRandomData( + tag=tag, + gif=gif, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Giphy random HTTP error status=%d", e.response.status_code) + return GiphyRandomResponse( + success=False, error=f"Giphy API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Giphy random failed") + return GiphyRandomResponse( + success=False, error=f"Giphy random failed: {str(e)}" + ) diff --git a/src/tools/media/lumalab.py b/src/tools/media/lumalab.py new file mode 100644 index 0000000..cf8cd21 --- /dev/null +++ b/src/tools/media/lumalab.py @@ -0,0 +1,140 @@ +"""Luma AI video generation tool.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + LumaLabVideoData, + LumaLabVideoResponse, +) + +logger = logging.getLogger("humcp.tools.lumalab") + +POLL_INTERVAL_SECONDS = 3 +MAX_WAIT_SECONDS = 300 + + +@tool() +async def lumalab_generate_video( + prompt: str, + keyframes: dict[str, dict[str, str]] | None = None, + loop: bool = False, + aspect_ratio: str = "16:9", +) -> LumaLabVideoResponse: + """Generate a video using Luma AI from a text prompt. + + Args: + prompt: A text description of the desired video content. + keyframes: Optional keyframe images for guided generation. + Format: {"frame0": {"type": "image", "url": "https://..."}} + loop: Whether the video should loop seamlessly. Default: False. + aspect_ratio: Aspect ratio of the output video. Options: "1:1", "16:9", + "9:16", "4:3", "3:4", "21:9", "9:21". Default: "16:9". + + Returns: + Video URL and generation status. + """ + try: + api_key = await resolve_credential("LUMAAI_API_KEY") + if not api_key: + return LumaLabVideoResponse( + success=False, + error="Luma AI API not configured. Set LUMAAI_API_KEY environment variable.", + ) + + if not prompt.strip(): + return LumaLabVideoResponse( + success=False, error="Prompt must not be empty." + ) + + valid_ratios = {"1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"} + if aspect_ratio not in valid_ratios: + return LumaLabVideoResponse( + success=False, + error=f"Invalid aspect_ratio '{aspect_ratio}'. Choose from: {', '.join(valid_ratios)}", + ) + + try: + from lumaai import LumaAI + except ImportError: + return LumaLabVideoResponse( + success=False, + error="lumaai package is required. Install with: pip install lumaai", + ) + + logger.info( + "LumaLab generating video prompt_length=%d aspect_ratio=%s", + len(prompt), + aspect_ratio, + ) + + client = LumaAI(auth_token=api_key) + + generation_params: dict[str, Any] = { + "prompt": prompt, + "loop": loop, + "aspect_ratio": aspect_ratio, + } + + if keyframes is not None: + generation_params["keyframes"] = keyframes + + generation = client.generations.create(**generation_params) + + if not generation or not generation.id: + return LumaLabVideoResponse( + success=False, error="Failed to start video generation." + ) + + generation_id = generation.id + + seconds_waited = 0 + while seconds_waited < MAX_WAIT_SECONDS: + generation = client.generations.get(generation_id) + + if generation.state == "completed" and generation.assets: + video_url = generation.assets.video + if video_url: + logger.info("LumaLab video generation completed url=%s", video_url) + return LumaLabVideoResponse( + success=True, + data=LumaLabVideoData( + prompt=prompt, + video_url=video_url, + state="completed", + generation_id=generation_id, + ), + ) + + if generation.state == "failed": + failure_reason = getattr(generation, "failure_reason", "Unknown error") + logger.error( + "LumaLab video generation failed reason=%s", failure_reason + ) + return LumaLabVideoResponse( + success=False, + error=f"Video generation failed: {failure_reason}", + ) + + logger.info( + "LumaLab generation in progress state=%s waited=%ds", + generation.state, + seconds_waited, + ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + seconds_waited += POLL_INTERVAL_SECONDS + + return LumaLabVideoResponse( + success=False, + error=f"Video generation timed out after {MAX_WAIT_SECONDS} seconds.", + ) + except Exception as e: + logger.exception("LumaLab video generation failed") + return LumaLabVideoResponse( + success=False, error=f"LumaLab video generation failed: {str(e)}" + ) diff --git a/src/tools/media/models_labs.py b/src/tools/media/models_labs.py new file mode 100644 index 0000000..2e62330 --- /dev/null +++ b/src/tools/media/models_labs.py @@ -0,0 +1,126 @@ +"""ModelsLab image generation tool.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + ModelsLabGenerateData, + ModelsLabGenerateResponse, +) + +logger = logging.getLogger("humcp.tools.models_labs") + +MODELS_LAB_TEXT2IMG_URL = "https://modelslab.com/api/v6/images/text2img" + + +@tool() +async def models_labs_generate_image( + prompt: str, + model_id: str = "flux", + width: int = 512, + height: int = 512, + num_inference_steps: int = 30, +) -> ModelsLabGenerateResponse: + """Generate an image using ModelsLab API from a text prompt. + + Args: + prompt: A text description of the desired image. + model_id: The ModelsLab model to use. Default: "flux". + width: Output image width in pixels. Default: 512. + height: Output image height in pixels. Default: 512. + num_inference_steps: Number of inference steps. Higher values produce + better quality but take longer. Default: 30. + + Returns: + Generated image URLs and status. + """ + try: + api_key = await resolve_credential("MODELS_LAB_API_KEY") + if not api_key: + return ModelsLabGenerateResponse( + success=False, + error="ModelsLab API not configured. Set MODELS_LAB_API_KEY environment variable.", + ) + + if not prompt.strip(): + return ModelsLabGenerateResponse( + success=False, error="Prompt must not be empty." + ) + + if width < 64 or height < 64: + return ModelsLabGenerateResponse( + success=False, error="Width and height must be at least 64 pixels." + ) + + payload = { + "key": api_key, + "prompt": prompt, + "model_id": model_id, + "width": width, + "height": height, + "samples": 1, + "num_inference_steps": num_inference_steps, + "safety_checker": "no", + "webhook": None, + "track_id": None, + } + + logger.info( + "ModelsLab generating image model=%s size=%dx%d", + model_id, + width, + height, + ) + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + MODELS_LAB_TEXT2IMG_URL, + json=payload, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + + result = response.json() + + status = result.get("status", "unknown") + if status == "error": + error_msg = result.get("message", "Unknown error from ModelsLab") + logger.error("ModelsLab error: %s", error_msg) + return ModelsLabGenerateResponse( + success=False, error=f"ModelsLab error: {error_msg}" + ) + + output_urls = result.get("output") or result.get("future_links", []) + eta = result.get("eta") + + logger.info( + "ModelsLab generation status=%s urls=%d eta=%s", + status, + len(output_urls), + eta, + ) + + return ModelsLabGenerateResponse( + success=True, + data=ModelsLabGenerateData( + prompt=prompt, + status=status, + output_urls=output_urls, + eta=int(eta) if eta is not None else None, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("ModelsLab HTTP error status=%d", e.response.status_code) + return ModelsLabGenerateResponse( + success=False, error=f"ModelsLab API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("ModelsLab generation failed") + return ModelsLabGenerateResponse( + success=False, error=f"ModelsLab generation failed: {str(e)}" + ) diff --git a/src/tools/media/moviepy_video.py b/src/tools/media/moviepy_video.py new file mode 100644 index 0000000..e368ce9 --- /dev/null +++ b/src/tools/media/moviepy_video.py @@ -0,0 +1,209 @@ +"""MoviePy video creation tool for assembling images into a video.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + MoviePyTrimData, + MoviePyTrimResponse, + MoviePyVideoData, + MoviePyVideoResponse, +) + +logger = logging.getLogger("humcp.tools.moviepy_video") + + +@tool() +async def moviepy_create_video( + image_paths: list[str], + output_path: str, + fps: int = 24, + duration_per_image: float = 2.0, +) -> MoviePyVideoResponse: + """Create a video from a sequence of images using MoviePy. + + Args: + image_paths: List of file paths to images to include in the video. + output_path: Path where the output video file will be saved (e.g., "output.mp4"). + fps: Frames per second of the output video. Default: 24. + duration_per_image: Duration in seconds each image is displayed. Default: 2.0. + + Returns: + Path to the created video file and metadata. + """ + try: + if not image_paths: + return MoviePyVideoResponse( + success=False, error="image_paths must not be empty." + ) + + if not output_path.strip(): + return MoviePyVideoResponse( + success=False, error="output_path must not be empty." + ) + + if fps < 1: + return MoviePyVideoResponse(success=False, error="fps must be at least 1.") + + if duration_per_image <= 0: + return MoviePyVideoResponse( + success=False, error="duration_per_image must be positive." + ) + + missing = [p for p in image_paths if not Path(p).exists()] + if missing: + return MoviePyVideoResponse( + success=False, + error=f"Image files not found: {', '.join(missing)}", + ) + + try: + from moviepy import ImageClip, concatenate_videoclips + except ImportError: + return MoviePyVideoResponse( + success=False, + error="moviepy package is required. Install with: pip install moviepy", + ) + + logger.info( + "MoviePy creating video images=%d fps=%d duration_per_image=%.1f", + len(image_paths), + fps, + duration_per_image, + ) + + clips = [] + for img_path in image_paths: + clip = ImageClip(img_path).with_duration(duration_per_image) + clips.append(clip) + + video = concatenate_videoclips(clips, method="compose") + video.write_videofile( + output_path, + fps=fps, + codec="libx264", + audio=False, + ) + + for clip in clips: + clip.close() + video.close() + + logger.info("MoviePy video created at %s", output_path) + + return MoviePyVideoResponse( + success=True, + data=MoviePyVideoData( + output_path=output_path, + num_images=len(image_paths), + fps=fps, + duration_per_image=duration_per_image, + ), + ) + except Exception as e: + logger.exception("MoviePy video creation failed") + return MoviePyVideoResponse( + success=False, error=f"MoviePy video creation failed: {str(e)}" + ) + + +@tool() +async def moviepy_trim_video( + input_path: str, + output_path: str, + start_time: float, + end_time: float, +) -> MoviePyTrimResponse: + """Trim a video to a specified time range using MoviePy. + + Args: + input_path: Path to the input video file. + output_path: Path where the trimmed video will be saved (e.g., "trimmed.mp4"). + start_time: Start time in seconds for the trim. + end_time: End time in seconds for the trim. + + Returns: + Path to the trimmed video file and trim metadata. + """ + try: + if not input_path.strip() or not output_path.strip(): + return MoviePyTrimResponse( + success=False, error="Input and output paths must not be empty." + ) + + if start_time < 0: + return MoviePyTrimResponse( + success=False, error="start_time must not be negative." + ) + + if end_time <= start_time: + return MoviePyTrimResponse( + success=False, error="end_time must be greater than start_time." + ) + + input_file = Path(input_path) + if not input_file.exists(): + return MoviePyTrimResponse( + success=False, error=f"Input file not found: {input_path}" + ) + + try: + from moviepy import VideoFileClip + except ImportError: + return MoviePyTrimResponse( + success=False, + error="moviepy package is required. Install with: pip install moviepy", + ) + + logger.info( + "MoviePy trimming %s from %.1fs to %.1fs", + input_path, + start_time, + end_time, + ) + + video = VideoFileClip(input_path) + + if end_time > video.duration: + video.close() + return MoviePyTrimResponse( + success=False, + error=f"end_time ({end_time}s) exceeds video duration ({video.duration:.1f}s).", + ) + + trimmed = video.subclipped(start_time, end_time) + duration = end_time - start_time + + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + + trimmed.write_videofile( + output_path, + codec="libx264", + audio_codec="aac", + ) + + trimmed.close() + video.close() + + logger.info( + "MoviePy trim complete output=%s duration=%.1fs", output_path, duration + ) + + return MoviePyTrimResponse( + success=True, + data=MoviePyTrimData( + output_path=output_path, + start_time=start_time, + end_time=end_time, + duration=duration, + ), + ) + except Exception as e: + logger.exception("MoviePy trim failed") + return MoviePyTrimResponse( + success=False, error=f"MoviePy trim failed: {str(e)}" + ) diff --git a/src/tools/media/nano_banana.py b/src/tools/media/nano_banana.py new file mode 100644 index 0000000..852fe8d --- /dev/null +++ b/src/tools/media/nano_banana.py @@ -0,0 +1,120 @@ +"""NanoBanana/Banana.dev AI model execution tool.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + NanoBananaRunData, + NanoBananaRunResponse, +) + +logger = logging.getLogger("humcp.tools.nano_banana") + +BANANA_API_URL = "https://api.banana.dev/start/v4" +BANANA_CHECK_URL = "https://api.banana.dev/check/v4" + + +@tool() +async def nano_banana_run( + model_name: str, + input_data: dict[str, Any] | None = None, +) -> NanoBananaRunResponse: + """Run an AI model on NanoBanana/Banana.dev and return generated outputs. + + Args: + model_name: The model key or name to run on Banana.dev. + input_data: Input parameters for the model as a dictionary. + Contents depend on the specific model being run. + + Returns: + Generated output URLs and status. + """ + try: + api_key = await resolve_credential("BANANA_API_KEY") + if not api_key: + return NanoBananaRunResponse( + success=False, + error="Banana API not configured. Set BANANA_API_KEY environment variable.", + ) + + if not model_name.strip(): + return NanoBananaRunResponse( + success=False, error="Model name must not be empty." + ) + + resolved_input = input_data if input_data is not None else {} + + payload = { + "apiKey": api_key, + "modelKey": model_name, + "modelInputs": resolved_input, + } + + logger.info("NanoBanana run model=%s", model_name) + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + BANANA_API_URL, + json=payload, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + + result = response.json() + + call_id = result.get("callID", "") + message = result.get("message", "") + + if message and "error" in message.lower(): + logger.error("NanoBanana error: %s", message) + return NanoBananaRunResponse( + success=False, error=f"NanoBanana error: {message}" + ) + + model_outputs = result.get("modelOutputs", []) + output_urls = [] + for output in model_outputs: + if isinstance(output, dict): + url = ( + output.get("url") + or output.get("image_url") + or output.get("video_url") + ) + if url: + output_urls.append(url) + elif isinstance(output, str): + output_urls.append(output) + + status = "completed" if model_outputs else "processing" + + logger.info( + "NanoBanana run complete call_id=%s outputs=%d status=%s", + call_id, + len(output_urls), + status, + ) + + return NanoBananaRunResponse( + success=True, + data=NanoBananaRunData( + model_name=model_name, + output_urls=output_urls, + status=status, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("NanoBanana HTTP error status=%d", e.response.status_code) + return NanoBananaRunResponse( + success=False, error=f"NanoBanana API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("NanoBanana run failed") + return NanoBananaRunResponse( + success=False, error=f"NanoBanana run failed: {str(e)}" + ) diff --git a/src/tools/media/opencv.py b/src/tools/media/opencv.py new file mode 100644 index 0000000..25aa5f2 --- /dev/null +++ b/src/tools/media/opencv.py @@ -0,0 +1,420 @@ +"""OpenCV image processing tools for resizing and format conversion.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + OpenCVConvertData, + OpenCVConvertResponse, + OpenCVCropData, + OpenCVCropResponse, + OpenCVResizeData, + OpenCVResizeResponse, + OpenCVRotateData, + OpenCVRotateResponse, +) + +logger = logging.getLogger("humcp.tools.opencv") + +SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"} + + +@tool() +async def opencv_resize_image( + input_path: str, + output_path: str, + width: int, + height: int, +) -> OpenCVResizeResponse: + """Resize an image to the specified dimensions using OpenCV. + + Args: + input_path: Path to the input image file. + output_path: Path where the resized image will be saved. + width: Target width in pixels. + height: Target height in pixels. + + Returns: + Paths and dimensions of the resized image. + """ + try: + if not input_path.strip() or not output_path.strip(): + return OpenCVResizeResponse( + success=False, error="Input and output paths must not be empty." + ) + + if width < 1 or height < 1: + return OpenCVResizeResponse( + success=False, error="Width and height must be at least 1 pixel." + ) + + input_file = Path(input_path) + if not input_file.exists(): + return OpenCVResizeResponse( + success=False, error=f"Input file not found: {input_path}" + ) + + if input_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVResizeResponse( + success=False, + error=f"Unsupported input format '{input_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + output_file = Path(output_path) + if output_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVResizeResponse( + success=False, + error=f"Unsupported output format '{output_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + try: + import cv2 + except ImportError: + return OpenCVResizeResponse( + success=False, + error="opencv-python package is required. Install with: pip install opencv-python", + ) + + logger.info("OpenCV resizing %s to %dx%d", input_path, width, height) + + img = cv2.imread(input_path) + if img is None: + return OpenCVResizeResponse( + success=False, error=f"Failed to read image: {input_path}" + ) + + output_file.parent.mkdir(parents=True, exist_ok=True) + resized = cv2.resize(img, (width, height), interpolation=cv2.INTER_AREA) + success = cv2.imwrite(output_path, resized) + + if not success: + return OpenCVResizeResponse( + success=False, error=f"Failed to write resized image to: {output_path}" + ) + + logger.info("OpenCV resize complete output=%s", output_path) + + return OpenCVResizeResponse( + success=True, + data=OpenCVResizeData( + input_path=input_path, + output_path=output_path, + width=width, + height=height, + ), + ) + except Exception as e: + logger.exception("OpenCV resize failed") + return OpenCVResizeResponse( + success=False, error=f"OpenCV resize failed: {str(e)}" + ) + + +@tool() +async def opencv_convert_format( + input_path: str, + output_path: str, +) -> OpenCVConvertResponse: + """Convert an image from one format to another using OpenCV. + + The output format is determined by the file extension of output_path. + + Args: + input_path: Path to the input image file. + output_path: Path where the converted image will be saved. + The file extension determines the output format + (e.g., ".png", ".jpg", ".bmp", ".webp"). + + Returns: + Paths and format information of the converted image. + """ + try: + if not input_path.strip() or not output_path.strip(): + return OpenCVConvertResponse( + success=False, error="Input and output paths must not be empty." + ) + + input_file = Path(input_path) + output_file = Path(output_path) + + if not input_file.exists(): + return OpenCVConvertResponse( + success=False, error=f"Input file not found: {input_path}" + ) + + input_ext = input_file.suffix.lower() + output_ext = output_file.suffix.lower() + + if input_ext not in SUPPORTED_FORMATS: + return OpenCVConvertResponse( + success=False, + error=f"Unsupported input format '{input_ext}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + if output_ext not in SUPPORTED_FORMATS: + return OpenCVConvertResponse( + success=False, + error=f"Unsupported output format '{output_ext}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + try: + import cv2 + except ImportError: + return OpenCVConvertResponse( + success=False, + error="opencv-python package is required. Install with: pip install opencv-python", + ) + + logger.info( + "OpenCV converting %s (%s) to %s (%s)", + input_path, + input_ext, + output_path, + output_ext, + ) + + img = cv2.imread(input_path) + if img is None: + return OpenCVConvertResponse( + success=False, error=f"Failed to read image: {input_path}" + ) + + output_file.parent.mkdir(parents=True, exist_ok=True) + success = cv2.imwrite(output_path, img) + + if not success: + return OpenCVConvertResponse( + success=False, + error=f"Failed to write converted image to: {output_path}", + ) + + logger.info("OpenCV conversion complete output=%s", output_path) + + return OpenCVConvertResponse( + success=True, + data=OpenCVConvertData( + input_path=input_path, + output_path=output_path, + input_format=input_ext.lstrip("."), + output_format=output_ext.lstrip("."), + ), + ) + except Exception as e: + logger.exception("OpenCV conversion failed") + return OpenCVConvertResponse( + success=False, error=f"OpenCV conversion failed: {str(e)}" + ) + + +@tool() +async def opencv_rotate_image( + input_path: str, + output_path: str, + angle: float, +) -> OpenCVRotateResponse: + """Rotate an image by a specified angle using OpenCV. + + The image is rotated around its center. The output canvas is expanded + to fit the entire rotated image without cropping. + + Args: + input_path: Path to the input image file. + output_path: Path where the rotated image will be saved. + angle: Rotation angle in degrees (positive = counter-clockwise). + + Returns: + Path to the rotated image, angle, and original dimensions. + """ + try: + if not input_path.strip() or not output_path.strip(): + return OpenCVRotateResponse( + success=False, error="Input and output paths must not be empty." + ) + + input_file = Path(input_path) + if not input_file.exists(): + return OpenCVRotateResponse( + success=False, error=f"Input file not found: {input_path}" + ) + + if input_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVRotateResponse( + success=False, + error=f"Unsupported input format '{input_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + output_file = Path(output_path) + if output_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVRotateResponse( + success=False, + error=f"Unsupported output format '{output_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + try: + import cv2 + import numpy as np + except ImportError: + return OpenCVRotateResponse( + success=False, + error="opencv-python and numpy packages are required. Install with: pip install opencv-python numpy", + ) + + logger.info("OpenCV rotating %s by %.1f degrees", input_path, angle) + + img = cv2.imread(input_path) + if img is None: + return OpenCVRotateResponse( + success=False, error=f"Failed to read image: {input_path}" + ) + + h, w = img.shape[:2] + original_size = f"{w}x{h}" + center = (w / 2, h / 2) + + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0) + + cos_val = np.abs(rotation_matrix[0, 0]) + sin_val = np.abs(rotation_matrix[0, 1]) + new_w = int(h * sin_val + w * cos_val) + new_h = int(h * cos_val + w * sin_val) + + rotation_matrix[0, 2] += (new_w / 2) - center[0] + rotation_matrix[1, 2] += (new_h / 2) - center[1] + + rotated = cv2.warpAffine(img, rotation_matrix, (new_w, new_h)) + + output_file.parent.mkdir(parents=True, exist_ok=True) + success = cv2.imwrite(output_path, rotated) + + if not success: + return OpenCVRotateResponse( + success=False, error=f"Failed to write rotated image to: {output_path}" + ) + + logger.info("OpenCV rotation complete output=%s", output_path) + + return OpenCVRotateResponse( + success=True, + data=OpenCVRotateData( + output_path=output_path, + angle=angle, + original_size=original_size, + ), + ) + except Exception as e: + logger.exception("OpenCV rotation failed") + return OpenCVRotateResponse( + success=False, error=f"OpenCV rotation failed: {str(e)}" + ) + + +@tool() +async def opencv_crop_image( + input_path: str, + output_path: str, + x: int, + y: int, + width: int, + height: int, +) -> OpenCVCropResponse: + """Crop a region from an image using OpenCV. + + Args: + input_path: Path to the input image file. + output_path: Path where the cropped image will be saved. + x: X coordinate of the top-left corner of the crop region. + y: Y coordinate of the top-left corner of the crop region. + width: Width of the crop region in pixels. + height: Height of the crop region in pixels. + + Returns: + Path to the cropped image, crop region, and original dimensions. + """ + try: + if not input_path.strip() or not output_path.strip(): + return OpenCVCropResponse( + success=False, error="Input and output paths must not be empty." + ) + + if width < 1 or height < 1: + return OpenCVCropResponse( + success=False, error="Crop width and height must be at least 1 pixel." + ) + + if x < 0 or y < 0: + return OpenCVCropResponse( + success=False, error="Crop x and y coordinates must not be negative." + ) + + input_file = Path(input_path) + if not input_file.exists(): + return OpenCVCropResponse( + success=False, error=f"Input file not found: {input_path}" + ) + + if input_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVCropResponse( + success=False, + error=f"Unsupported input format '{input_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + output_file = Path(output_path) + if output_file.suffix.lower() not in SUPPORTED_FORMATS: + return OpenCVCropResponse( + success=False, + error=f"Unsupported output format '{output_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", + ) + + try: + import cv2 + except ImportError: + return OpenCVCropResponse( + success=False, + error="opencv-python package is required. Install with: pip install opencv-python", + ) + + logger.info( + "OpenCV cropping %s region=(%d,%d,%d,%d)", input_path, x, y, width, height + ) + + img = cv2.imread(input_path) + if img is None: + return OpenCVCropResponse( + success=False, error=f"Failed to read image: {input_path}" + ) + + h, w = img.shape[:2] + original_size = f"{w}x{h}" + + if x + width > w or y + height > h: + return OpenCVCropResponse( + success=False, + error=f"Crop region ({x},{y},{width},{height}) exceeds image bounds ({w}x{h}).", + ) + + cropped = img[y : y + height, x : x + width] + + output_file.parent.mkdir(parents=True, exist_ok=True) + success = cv2.imwrite(output_path, cropped) + + if not success: + return OpenCVCropResponse( + success=False, error=f"Failed to write cropped image to: {output_path}" + ) + + logger.info("OpenCV crop complete output=%s", output_path) + + return OpenCVCropResponse( + success=True, + data=OpenCVCropData( + output_path=output_path, + crop_region=f"({x}, {y}, {width}, {height})", + original_size=original_size, + ), + ) + except Exception as e: + logger.exception("OpenCV crop failed") + return OpenCVCropResponse(success=False, error=f"OpenCV crop failed: {str(e)}") diff --git a/src/tools/media/replicate.py b/src/tools/media/replicate.py new file mode 100644 index 0000000..5532c66 --- /dev/null +++ b/src/tools/media/replicate.py @@ -0,0 +1,274 @@ +"""Replicate tools for running AI models and checking predictions.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + ReplicateOutput, + ReplicatePredictionData, + ReplicatePredictionResponse, + ReplicateRunData, + ReplicateRunResponse, + ReplicateSearchData, + ReplicateSearchModelItem, + ReplicateSearchResponse, +) + +logger = logging.getLogger("humcp.tools.replicate") + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"} +VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm"} + + +def _classify_media(url: str) -> str: + """Determine media type from URL file extension.""" + parsed = urlparse(url) + ext = Path(parsed.path).suffix.lower() + if ext in IMAGE_EXTENSIONS: + return "image" + if ext in VIDEO_EXTENSIONS: + return "video" + return "unknown" + + +@tool() +async def replicate_run_model( + model: str, + input_data: dict[str, Any] | None = None, +) -> ReplicateRunResponse: + """Run an AI model on Replicate and return generated media. + + Args: + model: The model identifier (e.g., "stability-ai/sdxl", "minimax/video-01"). + input_data: Input parameters for the model as a dictionary. + Typically includes "prompt" and model-specific options. + + Returns: + Generated media URLs and their types. + """ + try: + api_token = await resolve_credential("REPLICATE_API_TOKEN") + if not api_token: + return ReplicateRunResponse( + success=False, + error="Replicate API not configured. Set REPLICATE_API_TOKEN environment variable.", + ) + + if not model.strip(): + return ReplicateRunResponse( + success=False, error="Model identifier must not be empty." + ) + + try: + import replicate as replicate_pkg + except ImportError: + return ReplicateRunResponse( + success=False, + error="replicate package is required. Install with: pip install replicate", + ) + + resolved_input = input_data if input_data is not None else {} + + logger.info("Replicate run model=%s", model) + + os.environ["REPLICATE_API_TOKEN"] = api_token + raw_output = replicate_pkg.run(ref=model, input=resolved_input) + + from replicate.helpers import FileOutput + + if isinstance(raw_output, FileOutput): + output_list = [raw_output] + elif hasattr(raw_output, "__iter__") and not isinstance(raw_output, str): + output_list = list(raw_output) + else: + return ReplicateRunResponse( + success=False, + error=f"Unexpected output type: {type(raw_output).__name__}", + ) + + outputs = [] + for item in output_list: + if isinstance(item, FileOutput): + url = str(item.url) + media_type = _classify_media(url) + outputs.append(ReplicateOutput(url=url, media_type=media_type)) + elif isinstance(item, str): + media_type = _classify_media(item) + outputs.append(ReplicateOutput(url=item, media_type=media_type)) + + logger.info("Replicate run complete outputs=%d", len(outputs)) + + return ReplicateRunResponse( + success=True, + data=ReplicateRunData( + model=model, + outputs=outputs, + ), + ) + except Exception as e: + logger.exception("Replicate run failed") + return ReplicateRunResponse( + success=False, error=f"Replicate run failed: {str(e)}" + ) + + +@tool() +async def replicate_get_prediction( + prediction_id: str, +) -> ReplicatePredictionResponse: + """Check the status of a Replicate prediction. + + Args: + prediction_id: The prediction ID to check. + + Returns: + Prediction status, output URLs (if completed), or error details. + """ + try: + api_token = await resolve_credential("REPLICATE_API_TOKEN") + if not api_token: + return ReplicatePredictionResponse( + success=False, + error="Replicate API not configured. Set REPLICATE_API_TOKEN environment variable.", + ) + + if not prediction_id.strip(): + return ReplicatePredictionResponse( + success=False, error="Prediction ID must not be empty." + ) + + try: + import replicate as replicate_pkg + except ImportError: + return ReplicatePredictionResponse( + success=False, + error="replicate package is required. Install with: pip install replicate", + ) + + logger.info("Replicate get prediction id=%s", prediction_id) + + os.environ["REPLICATE_API_TOKEN"] = api_token + client = replicate_pkg.Client(api_token=api_token) + prediction = client.predictions.get(prediction_id) + + output = None + if prediction.output: + if isinstance(prediction.output, list): + output = [str(item) for item in prediction.output] + else: + output = [str(prediction.output)] + + error_msg = str(prediction.error) if prediction.error else None + + logger.info("Replicate prediction status=%s", prediction.status) + + return ReplicatePredictionResponse( + success=True, + data=ReplicatePredictionData( + prediction_id=prediction_id, + status=prediction.status, + output=output, + error=error_msg, + ), + ) + except Exception as e: + logger.exception("Replicate get prediction failed") + return ReplicatePredictionResponse( + success=False, error=f"Replicate get prediction failed: {str(e)}" + ) + + +REPLICATE_API_BASE = "https://api.replicate.com/v1" + + +@tool() +async def replicate_search_models( + query: str, +) -> ReplicateSearchResponse: + """Search for AI models on Replicate by keyword. + + Args: + query: The search query string (e.g., "image generation", "text to speech"). + + Returns: + List of matching models with owner, name, description, and run count. + """ + try: + api_token = await resolve_credential("REPLICATE_API_TOKEN") + if not api_token: + return ReplicateSearchResponse( + success=False, + error="Replicate API not configured. Set REPLICATE_API_TOKEN environment variable.", + ) + + if not query.strip(): + return ReplicateSearchResponse( + success=False, error="Query must not be empty." + ) + + headers = { + "Authorization": f"Bearer {api_token}", + } + + params = { + "query": query, + } + + logger.info("Replicate search models query=%s", query) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{REPLICATE_API_BASE}/models", + headers=headers, + params=params, + ) + response.raise_for_status() + + data = response.json() + results = data.get("results", []) + + models = [] + for item in results: + models.append( + ReplicateSearchModelItem( + owner=item.get("owner", ""), + name=item.get("name", ""), + description=item.get("description", ""), + url=item.get( + "url", + f"https://replicate.com/{item.get('owner', '')}/{item.get('name', '')}", + ), + run_count=item.get("run_count", 0), + ) + ) + + logger.info("Replicate search complete results=%d", len(models)) + + return ReplicateSearchResponse( + success=True, + data=ReplicateSearchData( + query=query, + models=models, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception( + "Replicate search HTTP error status=%d", e.response.status_code + ) + return ReplicateSearchResponse( + success=False, error=f"Replicate API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Replicate search models failed") + return ReplicateSearchResponse( + success=False, error=f"Replicate search failed: {str(e)}" + ) diff --git a/src/tools/media/schemas.py b/src/tools/media/schemas.py new file mode 100644 index 0000000..9a9959d --- /dev/null +++ b/src/tools/media/schemas.py @@ -0,0 +1,429 @@ +"""Pydantic output schemas for media tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Giphy Schemas +# ============================================================================= + + +class GiphyGif(BaseModel): + """A single GIF result from Giphy.""" + + url: str = Field(..., description="URL of the original GIF") + alt_text: str | None = Field(None, description="Alt text for the GIF") + title: str | None = Field(None, description="Title of the GIF") + + +class GiphySearchData(BaseModel): + """Output data for giphy_search tool.""" + + query: str = Field(..., description="The search query that was executed") + gifs: list[GiphyGif] = Field(default_factory=list, description="List of found GIFs") + total_count: int = Field(0, description="Total number of results available") + + +class GiphyTrendingData(BaseModel): + """Output data for giphy_trending tool.""" + + gifs: list[GiphyGif] = Field( + default_factory=list, description="List of trending GIFs" + ) + + +# ============================================================================= +# Unsplash Schemas +# ============================================================================= + + +class UnsplashPhoto(BaseModel): + """A single photo result from Unsplash.""" + + id: str = Field(..., description="Unique photo identifier") + description: str | None = Field(None, description="Photo description") + width: int | None = Field(None, description="Photo width in pixels") + height: int | None = Field(None, description="Photo height in pixels") + color: str | None = Field(None, description="Dominant color hex code") + urls: dict[str, str | None] = Field( + default_factory=dict, description="Photo URLs at different sizes" + ) + author_name: str | None = Field(None, description="Photographer name") + author_username: str | None = Field(None, description="Photographer username") + likes: int | None = Field(None, description="Number of likes") + + +class UnsplashSearchData(BaseModel): + """Output data for unsplash_search_photos tool.""" + + query: str = Field(..., description="The search query that was executed") + total: int = Field(0, description="Total number of results available") + photos: list[UnsplashPhoto] = Field( + default_factory=list, description="List of photo results" + ) + + +class UnsplashRandomPhotoData(BaseModel): + """Output data for unsplash_get_random_photo tool.""" + + query: str | None = Field(None, description="Optional query used to filter") + photos: list[UnsplashPhoto] = Field( + default_factory=list, description="List of random photos" + ) + + +# ============================================================================= +# DALL-E Schemas +# ============================================================================= + + +class DalleGeneratedImage(BaseModel): + """A single generated image from DALL-E.""" + + url: str = Field(..., description="URL of the generated image") + revised_prompt: str | None = Field( + None, description="The revised prompt used by DALL-E" + ) + + +class DalleGenerateImageData(BaseModel): + """Output data for dalle_generate_image tool.""" + + prompt: str = Field(..., description="The original prompt") + images: list[DalleGeneratedImage] = Field( + default_factory=list, description="List of generated images" + ) + + +# ============================================================================= +# Replicate Schemas +# ============================================================================= + + +class ReplicateOutput(BaseModel): + """A single output from a Replicate model run.""" + + url: str = Field(..., description="URL of the generated media") + media_type: str = Field(..., description="Type of media (image or video)") + + +class ReplicateRunData(BaseModel): + """Output data for replicate_run_model tool.""" + + model: str = Field(..., description="The model that was run") + outputs: list[ReplicateOutput] = Field( + default_factory=list, description="List of generated outputs" + ) + + +class ReplicatePredictionData(BaseModel): + """Output data for replicate_get_prediction tool.""" + + prediction_id: str = Field(..., description="The prediction ID") + status: str = Field(..., description="Current status of the prediction") + output: list[str] | None = Field(None, description="Output URLs when completed") + error: str | None = Field(None, description="Error message if failed") + + +# ============================================================================= +# LumaLab Schemas +# ============================================================================= + + +class LumaLabVideoData(BaseModel): + """Output data for lumalab_generate_video tool.""" + + prompt: str = Field(..., description="The prompt used for generation") + video_url: str | None = Field(None, description="URL of the generated video") + state: str = Field(..., description="Current state of the generation") + generation_id: str | None = Field(None, description="Luma generation ID") + + +# ============================================================================= +# Fal Schemas +# ============================================================================= + + +class FalOutput(BaseModel): + """A single output from a Fal model run.""" + + url: str = Field(..., description="URL of the generated media") + media_type: str = Field(..., description="Type of media (image or video)") + + +class FalRunData(BaseModel): + """Output data for fal_run_model tool.""" + + model_id: str = Field(..., description="The Fal model that was run") + outputs: list[FalOutput] = Field( + default_factory=list, description="List of generated outputs" + ) + + +# ============================================================================= +# MoviePy Schemas +# ============================================================================= + + +class MoviePyVideoData(BaseModel): + """Output data for moviepy_create_video tool.""" + + output_path: str = Field(..., description="Path to the created video file") + num_images: int = Field(..., description="Number of images used") + fps: int = Field(..., description="Frames per second of the output video") + duration_per_image: float = Field( + ..., description="Duration each image is shown in seconds" + ) + + +# ============================================================================= +# ModelsLab Schemas +# ============================================================================= + + +class ModelsLabGenerateData(BaseModel): + """Output data for models_labs_generate_image tool.""" + + prompt: str = Field(..., description="The prompt used for generation") + status: str = Field(..., description="Generation status") + output_urls: list[str] = Field( + default_factory=list, description="URLs of generated media" + ) + eta: int | None = Field(None, description="Estimated time in seconds until ready") + + +# ============================================================================= +# NanoBanana Schemas +# ============================================================================= + + +class NanoBananaRunData(BaseModel): + """Output data for nano_banana_run tool.""" + + model_name: str = Field(..., description="The model that was run") + output_urls: list[str] = Field( + default_factory=list, description="URLs of generated outputs" + ) + status: str = Field(..., description="Status of the generation") + + +# ============================================================================= +# OpenCV Schemas +# ============================================================================= + + +class OpenCVResizeData(BaseModel): + """Output data for opencv_resize_image tool.""" + + input_path: str = Field(..., description="Path to the input image") + output_path: str = Field(..., description="Path to the resized image") + width: int = Field(..., description="New width in pixels") + height: int = Field(..., description="New height in pixels") + + +class OpenCVConvertData(BaseModel): + """Output data for opencv_convert_format tool.""" + + input_path: str = Field(..., description="Path to the input image") + output_path: str = Field(..., description="Path to the converted image") + input_format: str = Field(..., description="Original image format") + output_format: str = Field(..., description="Target image format") + + +class GiphyRandomData(BaseModel): + """Output data for giphy_random tool.""" + + tag: str | None = Field(None, description="Tag used to filter the random GIF") + gif: GiphyGif = Field(..., description="The random GIF result") + + +class GiphyStickerSearchData(BaseModel): + """Output data for giphy sticker search.""" + + query: str = Field(..., description="The search query that was executed") + stickers: list[GiphyGif] = Field( + default_factory=list, description="List of found stickers" + ) + total_count: int = Field(0, description="Total number of results available") + + +class UnsplashGetPhotoData(BaseModel): + """Output data for unsplash_get_photo tool.""" + + photo: UnsplashPhoto = Field(..., description="The retrieved photo") + + +class ReplicateSearchModelItem(BaseModel): + """A single model result from Replicate search.""" + + owner: str = Field(..., description="Owner of the model") + name: str = Field(..., description="Name of the model") + description: str = Field("", description="Description of the model") + url: str = Field(..., description="URL of the model on Replicate") + run_count: int = Field(0, description="Number of times the model has been run") + + +class ReplicateSearchData(BaseModel): + """Output data for replicate_search_models tool.""" + + query: str = Field(..., description="The search query that was executed") + models: list[ReplicateSearchModelItem] = Field( + default_factory=list, description="List of matching models" + ) + + +class OpenCVRotateData(BaseModel): + """Output data for opencv_rotate_image tool.""" + + output_path: str = Field(..., description="Path to the rotated image") + angle: float = Field(..., description="Rotation angle in degrees") + original_size: str = Field(..., description="Original image dimensions (WxH)") + + +class OpenCVCropData(BaseModel): + """Output data for opencv_crop_image tool.""" + + output_path: str = Field(..., description="Path to the cropped image") + crop_region: str = Field(..., description="Crop region as (x, y, width, height)") + original_size: str = Field(..., description="Original image dimensions (WxH)") + + +class MoviePyTrimData(BaseModel): + """Output data for moviepy_trim_video tool.""" + + output_path: str = Field(..., description="Path to the trimmed video file") + start_time: float = Field(..., description="Start time of the trim in seconds") + end_time: float = Field(..., description="End time of the trim in seconds") + duration: float = Field(..., description="Duration of the trimmed video in seconds") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class GiphySearchResponse(ToolResponse[GiphySearchData]): + """Response schema for giphy_search tool.""" + + pass + + +class GiphyTrendingResponse(ToolResponse[GiphyTrendingData]): + """Response schema for giphy_trending tool.""" + + pass + + +class UnsplashSearchResponse(ToolResponse[UnsplashSearchData]): + """Response schema for unsplash_search_photos tool.""" + + pass + + +class UnsplashRandomPhotoResponse(ToolResponse[UnsplashRandomPhotoData]): + """Response schema for unsplash_get_random_photo tool.""" + + pass + + +class DalleGenerateImageResponse(ToolResponse[DalleGenerateImageData]): + """Response schema for dalle_generate_image tool.""" + + pass + + +class ReplicateRunResponse(ToolResponse[ReplicateRunData]): + """Response schema for replicate_run_model tool.""" + + pass + + +class ReplicatePredictionResponse(ToolResponse[ReplicatePredictionData]): + """Response schema for replicate_get_prediction tool.""" + + pass + + +class LumaLabVideoResponse(ToolResponse[LumaLabVideoData]): + """Response schema for lumalab_generate_video tool.""" + + pass + + +class FalRunResponse(ToolResponse[FalRunData]): + """Response schema for fal_run_model tool.""" + + pass + + +class MoviePyVideoResponse(ToolResponse[MoviePyVideoData]): + """Response schema for moviepy_create_video tool.""" + + pass + + +class ModelsLabGenerateResponse(ToolResponse[ModelsLabGenerateData]): + """Response schema for models_labs_generate_image tool.""" + + pass + + +class NanoBananaRunResponse(ToolResponse[NanoBananaRunData]): + """Response schema for nano_banana_run tool.""" + + pass + + +class OpenCVResizeResponse(ToolResponse[OpenCVResizeData]): + """Response schema for opencv_resize_image tool.""" + + pass + + +class OpenCVConvertResponse(ToolResponse[OpenCVConvertData]): + """Response schema for opencv_convert_format tool.""" + + pass + + +class GiphyRandomResponse(ToolResponse[GiphyRandomData]): + """Response schema for giphy_random tool.""" + + pass + + +class GiphyStickerSearchResponse(ToolResponse[GiphyStickerSearchData]): + """Response schema for giphy sticker search tool.""" + + pass + + +class UnsplashGetPhotoResponse(ToolResponse[UnsplashGetPhotoData]): + """Response schema for unsplash_get_photo tool.""" + + pass + + +class ReplicateSearchResponse(ToolResponse[ReplicateSearchData]): + """Response schema for replicate_search_models tool.""" + + pass + + +class OpenCVRotateResponse(ToolResponse[OpenCVRotateData]): + """Response schema for opencv_rotate_image tool.""" + + pass + + +class OpenCVCropResponse(ToolResponse[OpenCVCropData]): + """Response schema for opencv_crop_image tool.""" + + pass + + +class MoviePyTrimResponse(ToolResponse[MoviePyTrimData]): + """Response schema for moviepy_trim_video tool.""" + + pass diff --git a/src/tools/media/unsplash.py b/src/tools/media/unsplash.py new file mode 100644 index 0000000..84f7e60 --- /dev/null +++ b/src/tools/media/unsplash.py @@ -0,0 +1,287 @@ +"""Unsplash tools for searching and retrieving high-quality royalty-free photos.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.media.schemas import ( + UnsplashGetPhotoData, + UnsplashGetPhotoResponse, + UnsplashPhoto, + UnsplashRandomPhotoData, + UnsplashRandomPhotoResponse, + UnsplashSearchData, + UnsplashSearchResponse, +) + +logger = logging.getLogger("humcp.tools.unsplash") + +UNSPLASH_API_BASE = "https://api.unsplash.com" + + +def _format_photo(photo: dict) -> UnsplashPhoto: + """Extract relevant fields from an Unsplash API photo object.""" + urls = photo.get("urls", {}) + user = photo.get("user", {}) + return UnsplashPhoto( + id=photo.get("id", ""), + description=photo.get("description") or photo.get("alt_description"), + width=photo.get("width"), + height=photo.get("height"), + color=photo.get("color"), + urls={ + "raw": urls.get("raw"), + "full": urls.get("full"), + "regular": urls.get("regular"), + "small": urls.get("small"), + "thumb": urls.get("thumb"), + }, + author_name=user.get("name"), + author_username=user.get("username"), + likes=photo.get("likes"), + ) + + +@tool() +async def unsplash_search_photos( + query: str, + per_page: int = 10, + page: int = 1, + orientation: str | None = None, + color: str | None = None, +) -> UnsplashSearchResponse: + """Search for photos on Unsplash by keyword. + + Args: + query: The search query string (e.g., "mountain sunset", "office workspace"). + per_page: Number of results per page (1-30). Default: 10. + page: Page number to retrieve. Default: 1. + orientation: Filter by orientation: "landscape", "portrait", or "squarish". + color: Filter by color: "black_and_white", "black", "white", "yellow", + "orange", "red", "purple", "magenta", "green", "teal", "blue". + + Returns: + Search results with photo URLs, author info, and metadata. + """ + try: + access_key = await resolve_credential("UNSPLASH_ACCESS_KEY") + if not access_key: + return UnsplashSearchResponse( + success=False, + error="Unsplash API not configured. Set UNSPLASH_ACCESS_KEY environment variable.", + ) + + if not query.strip(): + return UnsplashSearchResponse( + success=False, error="Query must not be empty." + ) + + params: dict = { + "query": query, + "per_page": max(1, min(per_page, 30)), + "page": max(1, page), + } + + valid_orientations = {"landscape", "portrait", "squarish"} + if orientation and orientation in valid_orientations: + params["orientation"] = orientation + + valid_colors = { + "black_and_white", + "black", + "white", + "yellow", + "orange", + "red", + "purple", + "magenta", + "green", + "teal", + "blue", + } + if color and color in valid_colors: + params["color"] = color + + headers = { + "Authorization": f"Client-ID {access_key}", + "Accept-Version": "v1", + } + + logger.info("Unsplash search query=%s per_page=%d", query, params["per_page"]) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{UNSPLASH_API_BASE}/search/photos", + params=params, + headers=headers, + ) + response.raise_for_status() + + data = response.json() + photos = [_format_photo(photo) for photo in data.get("results", [])] + total = data.get("total", 0) + + logger.info("Unsplash search complete results=%d total=%d", len(photos), total) + + return UnsplashSearchResponse( + success=True, + data=UnsplashSearchData( + query=query, + total=total, + photos=photos, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Unsplash search HTTP error status=%d", e.response.status_code) + return UnsplashSearchResponse( + success=False, error=f"Unsplash API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Unsplash search failed") + return UnsplashSearchResponse( + success=False, error=f"Unsplash search failed: {str(e)}" + ) + + +@tool() +async def unsplash_get_random_photo( + query: str | None = None, + orientation: str | None = None, + count: int = 1, +) -> UnsplashRandomPhotoResponse: + """Get random photo(s) from Unsplash, optionally filtered by query. + + Args: + query: Optional search query to filter random photos. + orientation: Filter by orientation: "landscape", "portrait", or "squarish". + count: Number of random photos to return (1-30). Default: 1. + + Returns: + Random photo(s) with URLs and metadata. + """ + try: + access_key = await resolve_credential("UNSPLASH_ACCESS_KEY") + if not access_key: + return UnsplashRandomPhotoResponse( + success=False, + error="Unsplash API not configured. Set UNSPLASH_ACCESS_KEY environment variable.", + ) + + params: dict = { + "count": max(1, min(count, 30)), + } + + if query: + params["query"] = query + + valid_orientations = {"landscape", "portrait", "squarish"} + if orientation and orientation in valid_orientations: + params["orientation"] = orientation + + headers = { + "Authorization": f"Client-ID {access_key}", + "Accept-Version": "v1", + } + + logger.info("Unsplash random query=%s count=%d", query, params["count"]) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{UNSPLASH_API_BASE}/photos/random", + params=params, + headers=headers, + ) + response.raise_for_status() + + data = response.json() + + if isinstance(data, list): + photos = [_format_photo(photo) for photo in data] + else: + photos = [_format_photo(data)] + + logger.info("Unsplash random complete results=%d", len(photos)) + + return UnsplashRandomPhotoResponse( + success=True, + data=UnsplashRandomPhotoData( + query=query, + photos=photos, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Unsplash random HTTP error status=%d", e.response.status_code) + return UnsplashRandomPhotoResponse( + success=False, error=f"Unsplash API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Unsplash random photo failed") + return UnsplashRandomPhotoResponse( + success=False, error=f"Unsplash random photo failed: {str(e)}" + ) + + +@tool() +async def unsplash_get_photo( + photo_id: str, +) -> UnsplashGetPhotoResponse: + """Get a specific photo from Unsplash by its ID. + + Args: + photo_id: The unique identifier of the Unsplash photo. + + Returns: + Photo details including URLs, author info, and metadata. + """ + try: + access_key = await resolve_credential("UNSPLASH_ACCESS_KEY") + if not access_key: + return UnsplashGetPhotoResponse( + success=False, + error="Unsplash API not configured. Set UNSPLASH_ACCESS_KEY environment variable.", + ) + + if not photo_id.strip(): + return UnsplashGetPhotoResponse( + success=False, error="Photo ID must not be empty." + ) + + headers = { + "Authorization": f"Client-ID {access_key}", + "Accept-Version": "v1", + } + + logger.info("Unsplash get photo id=%s", photo_id) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{UNSPLASH_API_BASE}/photos/{photo_id}", + headers=headers, + ) + response.raise_for_status() + + data = response.json() + photo = _format_photo(data) + + logger.info("Unsplash get photo complete id=%s", photo_id) + + return UnsplashGetPhotoResponse( + success=True, + data=UnsplashGetPhotoData(photo=photo), + ) + except httpx.HTTPStatusError as e: + logger.exception( + "Unsplash get photo HTTP error status=%d", e.response.status_code + ) + return UnsplashGetPhotoResponse( + success=False, error=f"Unsplash API error: {e.response.status_code}" + ) + except Exception as e: + logger.exception("Unsplash get photo failed") + return UnsplashGetPhotoResponse( + success=False, error=f"Unsplash get photo failed: {str(e)}" + ) diff --git a/src/tools/memory/SKILL.md b/src/tools/memory/SKILL.md new file mode 100644 index 0000000..4fc479d --- /dev/null +++ b/src/tools/memory/SKILL.md @@ -0,0 +1,97 @@ +--- +name: persistent-memory +description: Store, search, and retrieve persistent memories and conversation history using Mem0 or Zep. Use when the user needs to remember facts, maintain context across sessions, or manage conversational memory. +--- + +# Memory Tools + +Tools for persistent memory storage and retrieval across conversations. + +## Mem0 Tools + +Store and search user memories with semantic search via Mem0. + +### Requirements + +Set environment variable: +- `MEM0_API_KEY`: Your Mem0 API key + +### Store a Memory + +```python +result = await mem0_add_memory( + content="User prefers Python for data analysis", + user_id="user-123", + metadata={"category": "preferences"} +) +``` + +### Search Memories + +```python +result = await mem0_search_memory( + query="programming preferences", + user_id="user-123", + limit=5 +) +``` + +### Get All Memories + +```python +result = await mem0_get_memories(user_id="user-123") +``` + +## Zep Tools + +Session-based conversation memory with knowledge graph extraction. + +### Requirements + +Set environment variables: +- `ZEP_API_KEY`: Your Zep API key + +### Add Message to Session + +```python +result = await zep_add_memory( + session_id="session-abc", + content="I work at Acme Corp as a data scientist", + role="user" +) +``` + +### Search Session Memory + +```python +result = await zep_search_memory( + session_id="session-abc", + query="work information", + limit=5 +) +``` + +### Get Session Context + +```python +result = await zep_get_session(session_id="session-abc") +``` + +### Response Format + +All tools return: + +```json +{ + "success": true, + "data": { ... } +} +``` + +## When to Use + +- Storing user preferences and facts for personalization +- Maintaining context across multiple conversation sessions +- Building knowledge graphs from conversation history +- Semantic search over previously stored information +- Retrieving session summaries and context diff --git a/src/tools/memory/__init__.py b/src/tools/memory/__init__.py new file mode 100644 index 0000000..b996062 --- /dev/null +++ b/src/tools/memory/__init__.py @@ -0,0 +1 @@ +# Persistent memory and conversation history tools diff --git a/src/tools/memory/mem0.py b/src/tools/memory/mem0.py new file mode 100644 index 0000000..c143cbe --- /dev/null +++ b/src/tools/memory/mem0.py @@ -0,0 +1,272 @@ +"""Mem0 persistent memory tools for storing and retrieving user memories.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.memory.schemas import ( + Mem0AddMemoryData, + Mem0AddMemoryResponse, + Mem0GetMemoriesData, + Mem0GetMemoriesResponse, + Mem0Memory, + Mem0SearchMemoryData, + Mem0SearchMemoryResponse, +) + +try: + from mem0 import MemoryClient +except ImportError as err: + raise ImportError( + "mem0ai is required for Mem0 tools. Install with: pip install mem0ai" + ) from err + +logger = logging.getLogger("humcp.tools.mem0") + + +def _get_client(api_key: str) -> MemoryClient: + """Create a Mem0 MemoryClient using the provided API key. + + Args: + api_key: The Mem0 API key. + + Returns: + A configured MemoryClient instance. + """ + return MemoryClient(api_key=api_key) + + +def _parse_memory(raw: dict[str, Any]) -> Mem0Memory: + """Parse a raw memory dict from Mem0 into a Mem0Memory model.""" + return Mem0Memory( + id=raw.get("id"), + memory=raw.get("memory", ""), + metadata=raw.get("metadata"), + created_at=raw.get("created_at"), + updated_at=raw.get("updated_at"), + ) + + +@tool() +async def mem0_add_memory( + content: str, + user_id: str, + metadata: dict[str, Any] | None = None, +) -> Mem0AddMemoryResponse: + """Store a memory for a user in Mem0's managed memory platform. + + Sends a message to the Mem0 MemoryClient.add() API, which asynchronously + extracts and stores relevant facts from the content. The memory is associated + with the given user_id and can be retrieved or searched later. Mem0 automatically + deduplicates, merges, and manages memory lifecycle. + + Args: + content: The text content to store as a memory. Mem0 will extract key facts + and relationships from this text. Must not be empty. + user_id: The user ID to associate the memory with. All memories for a user + are scoped by this identifier. Must not be empty. + metadata: Optional key-value metadata to attach to the memory for filtering + and categorization (e.g., {"source": "chat", "topic": "preferences"}). + + Returns: + Confirmation of the stored memory with Mem0 operation results including + any extracted or updated memory entries. + """ + try: + if not content.strip(): + return Mem0AddMemoryResponse( + success=False, error="content must not be empty" + ) + if not user_id.strip(): + return Mem0AddMemoryResponse( + success=False, error="user_id must not be empty" + ) + + api_key = await resolve_credential("MEM0_API_KEY") + if not api_key: + return Mem0AddMemoryResponse( + success=False, error="MEM0_API_KEY not configured." + ) + + logger.info( + "Mem0 add memory user_id=%s content_length=%d", + user_id, + len(content), + ) + + client = _get_client(api_key) + messages = [{"role": "user", "content": content}] + + kwargs: dict[str, Any] = {"user_id": user_id} + if metadata: + kwargs["metadata"] = metadata + + result = client.add(messages, **kwargs) + + results_list: list[dict[str, Any]] = [] + if isinstance(result, dict) and "results" in result: + results_list = result.get("results", []) + elif isinstance(result, list): + results_list = result + + logger.info("Mem0 add memory complete results=%d", len(results_list)) + + return Mem0AddMemoryResponse( + success=True, + data=Mem0AddMemoryData( + message=f"Memory stored for user {user_id}", + results=results_list, + ), + ) + except ValueError as e: + return Mem0AddMemoryResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Mem0 add memory failed") + return Mem0AddMemoryResponse( + success=False, error=f"Mem0 add memory failed: {str(e)}" + ) + + +@tool() +async def mem0_search_memory( + query: str, + user_id: str, + limit: int = 10, +) -> Mem0SearchMemoryResponse: + """Search stored memories for a user using Mem0's semantic search. + + Uses the Mem0 MemoryClient.search() API to perform vector-based semantic + search across all memories associated with the given user. Results are ranked + by relevance to the query. This is useful for retrieving contextually relevant + memories to augment AI agent responses. + + Args: + query: The natural language search query to find relevant memories. + Mem0 uses embedding-based similarity matching. Must not be empty. + user_id: The user ID whose memories to search. Only memories stored under + this user_id are included in the search. Must not be empty. + limit: Maximum number of results to return. Defaults to 10. Must be at + least 1. + + Returns: + A list of matching memories ranked by semantic relevance, each containing + the memory text, metadata, and timestamps. + """ + try: + if not query.strip(): + return Mem0SearchMemoryResponse( + success=False, error="query must not be empty" + ) + if not user_id.strip(): + return Mem0SearchMemoryResponse( + success=False, error="user_id must not be empty" + ) + if limit < 1: + return Mem0SearchMemoryResponse( + success=False, error="limit must be at least 1" + ) + + api_key = await resolve_credential("MEM0_API_KEY") + if not api_key: + return Mem0SearchMemoryResponse( + success=False, error="MEM0_API_KEY not configured." + ) + + logger.info( + "Mem0 search memory user_id=%s query_length=%d limit=%d", + user_id, + len(query), + limit, + ) + + client = _get_client(api_key) + results = client.search(query=query, user_id=user_id, limit=limit) + + # Normalize results from Mem0 response + raw_results: list[dict[str, Any]] + if isinstance(results, dict) and "results" in results: + raw_results = results.get("results", []) + elif isinstance(results, list): + raw_results = results + else: + raw_results = [] + + memories = [_parse_memory(r) for r in raw_results] + + logger.info("Mem0 search memory complete results=%d", len(memories)) + + return Mem0SearchMemoryResponse( + success=True, + data=Mem0SearchMemoryData(query=query, results=memories), + ) + except ValueError as e: + return Mem0SearchMemoryResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Mem0 search memory failed") + return Mem0SearchMemoryResponse( + success=False, error=f"Mem0 search memory failed: {str(e)}" + ) + + +@tool() +async def mem0_get_memories( + user_id: str, +) -> Mem0GetMemoriesResponse: + """Retrieve all stored memories for a user from Mem0. + + Uses the Mem0 MemoryClient.get_all() API to fetch every memory associated + with the given user_id. Unlike search(), this returns all memories without + any relevance filtering. Useful for building a complete user profile or + performing bulk memory management operations. + + Args: + user_id: The user ID whose memories to retrieve. Must not be empty. + + Returns: + A complete list of all memories stored for the given user, each + containing the memory text, metadata, and timestamps. + """ + try: + if not user_id.strip(): + return Mem0GetMemoriesResponse( + success=False, error="user_id must not be empty" + ) + + api_key = await resolve_credential("MEM0_API_KEY") + if not api_key: + return Mem0GetMemoriesResponse( + success=False, error="MEM0_API_KEY not configured." + ) + + logger.info("Mem0 get memories user_id=%s", user_id) + + client = _get_client(api_key) + results = client.get_all(user_id=user_id) + + # Normalize results from Mem0 response + raw_results: list[dict[str, Any]] + if isinstance(results, dict) and "results" in results: + raw_results = results.get("results", []) + elif isinstance(results, list): + raw_results = results + else: + raw_results = [] + + memories = [_parse_memory(r) for r in raw_results] + + logger.info("Mem0 get memories complete memories=%d", len(memories)) + + return Mem0GetMemoriesResponse( + success=True, + data=Mem0GetMemoriesData(user_id=user_id, memories=memories), + ) + except ValueError as e: + return Mem0GetMemoriesResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Mem0 get memories failed") + return Mem0GetMemoriesResponse( + success=False, error=f"Mem0 get memories failed: {str(e)}" + ) diff --git a/src/tools/memory/schemas.py b/src/tools/memory/schemas.py new file mode 100644 index 0000000..a26fb62 --- /dev/null +++ b/src/tools/memory/schemas.py @@ -0,0 +1,129 @@ +"""Pydantic output schemas for memory tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Mem0 Schemas +# ============================================================================= + + +class Mem0Memory(BaseModel): + """A single memory entry from Mem0.""" + + id: str | None = Field(None, description="Memory ID") + memory: str = Field(..., description="Memory content") + metadata: dict[str, Any] | None = Field(None, description="Associated metadata") + created_at: str | None = Field(None, description="Creation timestamp") + updated_at: str | None = Field(None, description="Last update timestamp") + + +class Mem0AddMemoryData(BaseModel): + """Output data for mem0_add_memory tool.""" + + message: str = Field(..., description="Confirmation message") + results: list[dict[str, Any]] = Field( + default_factory=list, description="Memory operation results from Mem0" + ) + + +class Mem0SearchMemoryData(BaseModel): + """Output data for mem0_search_memory tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[Mem0Memory] = Field( + default_factory=list, description="List of matching memories" + ) + + +class Mem0GetMemoriesData(BaseModel): + """Output data for mem0_get_memories tool.""" + + user_id: str = Field(..., description="User ID for which memories were retrieved") + memories: list[Mem0Memory] = Field( + default_factory=list, description="List of all memories" + ) + + +# ============================================================================= +# Zep Schemas +# ============================================================================= + + +class ZepMessageData(BaseModel): + """Output data for zep_add_memory tool.""" + + session_id: str = Field(..., description="Zep session ID") + message: str = Field(..., description="Confirmation message") + + +class ZepMemoryResult(BaseModel): + """A single memory result from Zep search.""" + + fact: str | None = Field(default=None, description="Fact text from edges") + name: str | None = Field(default=None, description="Node name") + summary: str | None = Field(default=None, description="Node summary") + score: float | None = Field(default=None, description="Relevance score") + + +class ZepSearchMemoryData(BaseModel): + """Output data for zep_search_memory tool.""" + + session_id: str = Field(..., description="Zep session ID") + query: str = Field(..., description="The search query that was executed") + scope: str = Field(..., description="Search scope (edges or nodes)") + results: list[ZepMemoryResult] = Field( + default_factory=list, description="List of matching memory results" + ) + + +class ZepSessionData(BaseModel): + """Output data for zep_get_session tool.""" + + session_id: str = Field(..., description="Zep session ID") + context: str | None = Field(None, description="Session context summary") + message_count: int = Field(0, description="Number of messages in the session") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class Mem0AddMemoryResponse(ToolResponse[Mem0AddMemoryData]): + """Response schema for mem0_add_memory tool.""" + + pass + + +class Mem0SearchMemoryResponse(ToolResponse[Mem0SearchMemoryData]): + """Response schema for mem0_search_memory tool.""" + + pass + + +class Mem0GetMemoriesResponse(ToolResponse[Mem0GetMemoriesData]): + """Response schema for mem0_get_memories tool.""" + + pass + + +class ZepAddMemoryResponse(ToolResponse[ZepMessageData]): + """Response schema for zep_add_memory tool.""" + + pass + + +class ZepSearchMemoryResponse(ToolResponse[ZepSearchMemoryData]): + """Response schema for zep_search_memory tool.""" + + pass + + +class ZepGetSessionResponse(ToolResponse[ZepSessionData]): + """Response schema for zep_get_session tool.""" + + pass diff --git a/src/tools/memory/zep.py b/src/tools/memory/zep.py new file mode 100644 index 0000000..be09799 --- /dev/null +++ b/src/tools/memory/zep.py @@ -0,0 +1,294 @@ +"""Zep conversation memory tools for session-based memory management.""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.memory.schemas import ( + ZepAddMemoryResponse, + ZepGetSessionResponse, + ZepMemoryResult, + ZepMessageData, + ZepSearchMemoryData, + ZepSearchMemoryResponse, + ZepSessionData, +) + +try: + from zep_cloud import Message as ZepMessage + from zep_cloud.client import AsyncZep +except ImportError as err: + raise ImportError( + "zep-cloud is required for Zep tools. Install with: pip install zep-cloud" + ) from err + +logger = logging.getLogger("humcp.tools.zep") + + +def _get_client(api_key: str) -> AsyncZep: + """Create an async Zep client using the provided API key. + + Args: + api_key: The Zep API key. + + Returns: + A configured AsyncZep client instance. + """ + return AsyncZep(api_key=api_key) + + +@tool() +async def zep_add_memory( + session_id: str, + content: str, + role: str = "user", +) -> ZepAddMemoryResponse: + """Add a message to a Zep Cloud session (thread) memory. + + Sends a message to the Zep Cloud AsyncZep thread.add_messages() API. Zep + automatically builds a knowledge graph from conversation messages, extracting + entities, relationships, and facts that can be searched later via graph search. + Messages are persisted in the thread's chronological history. + + Args: + session_id: The Zep thread ID to add the message to. This is Zep's + session/conversation identifier. Must not be empty. + content: The text content of the message. Zep will extract facts and + entities from this content for its knowledge graph. Must not + be empty. + role: The role of the message sender. Common values: 'user', 'assistant', + 'system'. Defaults to 'user'. Maps to Zep's Message role_type. + + Returns: + Confirmation of the stored message with the session ID. + """ + try: + if not session_id.strip(): + return ZepAddMemoryResponse( + success=False, error="session_id must not be empty" + ) + if not content.strip(): + return ZepAddMemoryResponse( + success=False, error="content must not be empty" + ) + + api_key = await resolve_credential("ZEP_API_KEY") + if not api_key: + return ZepAddMemoryResponse( + success=False, error="ZEP_API_KEY not configured." + ) + + logger.info( + "Zep add memory session_id=%s role=%s content_length=%d", + session_id, + role, + len(content), + ) + + client = _get_client(api_key) + zep_message = ZepMessage( # type: ignore[call-arg] + role=role, + content=content, + role_type=role, + ) + + await client.thread.add_messages( + thread_id=session_id, + messages=[zep_message], + ) + + logger.info("Zep add memory complete session_id=%s", session_id) + + return ZepAddMemoryResponse( + success=True, + data=ZepMessageData( + session_id=session_id, + message=f"Message from '{role}' added to session {session_id}", + ), + ) + except ValueError as e: + return ZepAddMemoryResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Zep add memory failed") + return ZepAddMemoryResponse( + success=False, error=f"Zep add memory failed: {str(e)}" + ) + + +@tool() +async def zep_search_memory( + session_id: str, + query: str, + limit: int = 5, +) -> ZepSearchMemoryResponse: + """Search Zep Cloud session memory for relevant facts using graph search. + + Uses the Zep Cloud AsyncZep graph.search() API to perform semantic search + across the knowledge graph built from conversation messages. Searches the + 'edges' scope by default, which returns fact-level results extracted from + the conversation. The search query is limited to 400 characters by Zep's API. + + First retrieves the thread to resolve the associated user_id, then performs + graph search scoped to that user's knowledge graph. + + Args: + session_id: The Zep thread ID to search within. Used to resolve the + associated user_id for graph search. Must not be empty. + query: The natural language search query to find relevant facts and + memories. Zep uses MMR reranking for diverse, relevant results. + Must not be empty. + limit: Maximum number of results to return. Defaults to 5. Must be at + least 1. + + Returns: + A list of matching facts from the session's knowledge graph, each with + a relevance score. + """ + try: + if not session_id.strip(): + return ZepSearchMemoryResponse( + success=False, error="session_id must not be empty" + ) + if not query.strip(): + return ZepSearchMemoryResponse( + success=False, error="query must not be empty" + ) + if limit < 1: + return ZepSearchMemoryResponse( + success=False, error="limit must be at least 1" + ) + + api_key = await resolve_credential("ZEP_API_KEY") + if not api_key: + return ZepSearchMemoryResponse( + success=False, error="ZEP_API_KEY not configured." + ) + + logger.info( + "Zep search memory session_id=%s query_length=%d limit=%d", + session_id, + len(query), + limit, + ) + + client = _get_client(api_key) + + # Zep graph search uses user_id, but we search via session context + # First get the session to find the user_id + session_info = await client.thread.get(thread_id=session_id) + user_id = getattr(session_info, "user_id", None) + + if not user_id: + return ZepSearchMemoryResponse( + success=False, + error=f"No user associated with session {session_id}", + ) + + search_response = await client.graph.search( + query=query, + user_id=user_id, + scope="edges", + limit=limit, + ) + + results: list[ZepMemoryResult] = [] + if search_response.edges: + for edge in search_response.edges: + results.append( + ZepMemoryResult( + fact=edge.fact, + score=getattr(edge, "score", None), + ) + ) + + logger.info("Zep search memory complete results=%d", len(results)) + + return ZepSearchMemoryResponse( + success=True, + data=ZepSearchMemoryData( + session_id=session_id, + query=query, + scope="edges", + results=results, + ), + ) + except ValueError as e: + return ZepSearchMemoryResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Zep search memory failed") + return ZepSearchMemoryResponse( + success=False, error=f"Zep search memory failed: {str(e)}" + ) + + +@tool() +async def zep_get_session( + session_id: str, +) -> ZepGetSessionResponse: + """Retrieve session context and metadata from Zep Cloud. + + Uses the Zep Cloud AsyncZep thread.get_user_context() API to fetch the + session's context summary, which is generated by performing graph search + on nodes, edges, and episodes using the MMR reranker against the most + recent messages. Also retrieves the thread metadata to count messages. + + The context summary is useful for providing an AI agent with relevant + background about the user's conversation history and preferences. + + Args: + session_id: The Zep thread ID to retrieve. Must not be empty. + + Returns: + Session context summary (auto-generated by Zep from the knowledge + graph) and total message count for the thread. + """ + try: + if not session_id.strip(): + return ZepGetSessionResponse( + success=False, error="session_id must not be empty" + ) + + api_key = await resolve_credential("ZEP_API_KEY") + if not api_key: + return ZepGetSessionResponse( + success=False, error="ZEP_API_KEY not configured." + ) + + logger.info("Zep get session session_id=%s", session_id) + + client = _get_client(api_key) + + # Get session context + context_response = await client.thread.get_user_context( # type: ignore[call-arg] + thread_id=session_id, mode="basic" + ) + context = getattr(context_response, "context", None) + + # Get session messages for count + session_info = await client.thread.get(thread_id=session_id) + messages = getattr(session_info, "messages", None) + message_count = len(messages) if messages else 0 + + logger.info( + "Zep get session complete session_id=%s message_count=%d", + session_id, + message_count, + ) + + return ZepGetSessionResponse( + success=True, + data=ZepSessionData( + session_id=session_id, + context=context, + message_count=message_count, + ), + ) + except ValueError as e: + return ZepGetSessionResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Zep get session failed") + return ZepGetSessionResponse( + success=False, error=f"Zep get session failed: {str(e)}" + ) diff --git a/src/tools/messaging/SKILL.md b/src/tools/messaging/SKILL.md new file mode 100644 index 0000000..72f9444 --- /dev/null +++ b/src/tools/messaging/SKILL.md @@ -0,0 +1,110 @@ +--- +name: messaging-communication +description: Send messages and notifications across messaging platforms including Slack, Discord, Telegram, and WhatsApp. Use when the user needs to send messages, list channels, search messages, or retrieve chat history. +--- + +# Messaging & Communication Tools + +Tools for sending messages and interacting with popular messaging platforms. + +## Available Tools + +| Tool | Service | Functions | +|------|---------|-----------| +| Slack | Slack | Send messages, list channels, get history, search, reactions, threads | +| Discord | Discord | Send messages, list guild channels, reactions, threads | +| Telegram | Telegram Bot API | Send messages, photos, edit messages, get updates, pin messages | +| WhatsApp | WhatsApp Cloud API | Send text, template, and media messages | + +## Requirements + +Set environment variables for each service you want to use: + +- **Slack**: `SLACK_TOKEN` +- **Discord**: `DISCORD_BOT_TOKEN` +- **Telegram**: `TELEGRAM_BOT_TOKEN` +- **WhatsApp**: `WHATSAPP_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` + +## Examples + +### Send a Slack message + +```python +result = await slack_send_message( + channel="#general", + text="Hello from the workflow!" +) +``` + +### List Slack channels + +```python +result = await slack_list_channels() +``` + +### Search Slack messages + +```python +result = await slack_search_messages( + query="in:#engineering deployment", + limit=10 +) +``` + +### Send a Discord message + +```python +result = await discord_send_message( + channel_id="1234567890", + content="Build completed successfully!" +) +``` + +### Send a Telegram message + +```python +result = await telegram_send_message( + chat_id="@mychannel", + text="Deployment finished." +) +``` + +### Send a WhatsApp message + +```python +result = await whatsapp_send_message( + to="1234567890", + message="Your order has been shipped!" +) +``` + +### Response format + +All tools return a consistent response format: + +```json +{ + "success": true, + "data": { + "message_id": "abc123", + "channel": "#general", + "timestamp": "1234567890.123456" + } +} +``` + +On error: + +```json +{ + "success": false, + "error": "Slack not configured. Set SLACK_TOKEN environment variable." +} +``` + +## When to Use + +- Sending notifications from automated workflows +- Posting alerts to team channels +- Integrating messaging into multi-step agent pipelines +- Forwarding information between communication platforms diff --git a/src/tools/messaging/__init__.py b/src/tools/messaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/messaging/discord.py b/src/tools/messaging/discord.py new file mode 100644 index 0000000..88ec3a8 --- /dev/null +++ b/src/tools/messaging/discord.py @@ -0,0 +1,411 @@ +"""Discord messaging tools for sending messages, managing reactions, threads, and channels. + +Uses the Discord Bot API v10 (https://discord.com/developers/docs). +Requires the DISCORD_BOT_TOKEN environment variable to be set. +""" + +from __future__ import annotations + +import logging +import os +from urllib.parse import quote as url_quote + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + AddReactionResponse, + ChannelInfo, + DiscordCreateThreadResponse, + DiscordMessageInfo, + DiscordThreadCreatedData, + GetMessagesData, + GetMessagesResponse, + ListChannelsData, + ListChannelsResponse, + MessageSentData, + ReactionAddedData, + SendMessageResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Discord tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.discord") + +DISCORD_API_BASE = "https://discord.com/api/v10" + + +def _get_headers() -> dict[str, str] | None: + """Build Discord API headers from the environment token.""" + token = os.getenv("DISCORD_BOT_TOKEN") + if not token: + return None + return { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + + +@tool() +async def discord_send_message( + channel_id: str, + content: str, + embed_title: str | None = None, + embed_description: str | None = None, +) -> SendMessageResponse: + """Send a message to a Discord channel, optionally with a rich embed. + + Uses the POST /channels/{channel_id}/messages endpoint. + Requires the SEND_MESSAGES permission in the target channel. + + Args: + channel_id: The ID of the Discord channel to send the message to. + content: The text content of the message (up to 2000 characters). + embed_title: Optional title for a rich embed attached to the message. + embed_description: Optional description for the rich embed (up to 4096 characters). + + Returns: + Response indicating success with message details, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return SendMessageResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + payload: dict = {"content": content} + if embed_title or embed_description: + embed: dict = {} + if embed_title: + embed["title"] = embed_title + if embed_description: + embed["description"] = embed_description + payload["embeds"] = [embed] + + logger.info("Sending Discord message to channel_id=%s", channel_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{DISCORD_API_BASE}/channels/{channel_id}/messages", + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=data.get("id"), + channel=data.get("channel_id"), + timestamp=data.get("timestamp"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Discord message: {str(e)}" + ) + + +@tool() +async def discord_add_reaction( + channel_id: str, message_id: str, emoji: str +) -> AddReactionResponse: + """Add a reaction emoji to a Discord message. + + Uses the PUT /channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me endpoint. + Requires READ_MESSAGE_HISTORY permission. Also requires ADD_REACTIONS if no one + else has reacted with the same emoji yet. + + Args: + channel_id: The ID of the channel containing the message. + message_id: The ID of the message to react to. + emoji: The emoji to react with. Use Unicode emoji (e.g., "thumbsup") or + custom emoji in the format "name:id" (e.g., "myemoji:123456789"). + + Returns: + Response indicating success, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return AddReactionResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + encoded_emoji = url_quote(emoji) + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded_emoji}/@me" + + logger.info( + "Adding reaction emoji=%s to message=%s in channel=%s", + emoji, + message_id, + channel_id, + ) + async with httpx.AsyncClient() as client: + response = await client.put( + url, + headers=headers, + timeout=30, + ) + response.raise_for_status() + + return AddReactionResponse( + success=True, + data=ReactionAddedData( + message_id=message_id, + emoji=emoji, + channel_id=channel_id, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord add_reaction HTTP error") + return AddReactionResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord add_reaction failed") + return AddReactionResponse( + success=False, error=f"Failed to add Discord reaction: {str(e)}" + ) + + +@tool() +async def discord_get_messages(channel_id: str, limit: int = 50) -> GetMessagesResponse: + """Fetch recent messages from a Discord channel. + + Uses the GET /channels/{channel_id}/messages endpoint. + Requires the READ_MESSAGE_HISTORY permission. + + Args: + channel_id: The ID of the Discord channel to fetch messages from. + limit: Maximum number of messages to retrieve (1-100, default 50). + + Returns: + Response containing a list of messages from the channel. + """ + try: + headers = _get_headers() + if headers is None: + return GetMessagesResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + if limit < 1: + return GetMessagesResponse(success=False, error="limit must be at least 1") + + capped_limit = min(limit, 100) + logger.info( + "Fetching Discord messages channel_id=%s limit=%d", channel_id, capped_limit + ) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{DISCORD_API_BASE}/channels/{channel_id}/messages", + headers=headers, + params={"limit": capped_limit}, + timeout=30, + ) + response.raise_for_status() + raw_messages = response.json() + + messages = [ + DiscordMessageInfo( + id=msg["id"], + content=msg.get("content", ""), + author=msg.get("author", {}).get("username"), + timestamp=msg.get("timestamp"), + channel_id=msg.get("channel_id"), + ) + for msg in raw_messages + ] + + return GetMessagesResponse( + success=True, + data=GetMessagesData( + messages=messages, count=len(messages), channel_id=channel_id + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord get_messages HTTP error") + return GetMessagesResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord get_messages failed") + return GetMessagesResponse( + success=False, error=f"Failed to get Discord messages: {str(e)}" + ) + + +@tool() +async def discord_list_channels(guild_id: str) -> ListChannelsResponse: + """List all channels in a Discord server (guild). + + Uses the GET /guilds/{guild_id}/channels endpoint. + Returns text, voice, category, news, stage, and forum channel types. + + Args: + guild_id: The ID of the Discord server (guild) to list channels from. + + Returns: + Response containing a list of channels with their IDs, names, and types. + """ + try: + headers = _get_headers() + if headers is None: + return ListChannelsResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + logger.info("Listing Discord channels for guild_id=%s", guild_id) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", + headers=headers, + timeout=30, + ) + response.raise_for_status() + raw_channels = response.json() + + # Discord channel types: 0=text, 2=voice, 4=category, 5=news, 13=stage, 15=forum + type_map = { + 0: "text", + 2: "voice", + 4: "category", + 5: "news", + 13: "stage", + 15: "forum", + } + + channels = [ + ChannelInfo( + id=ch["id"], + name=ch.get("name", ""), + type=type_map.get(ch.get("type", 0), "other"), + ) + for ch in raw_channels + ] + + return ListChannelsResponse( + success=True, + data=ListChannelsData(channels=channels, count=len(channels)), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord list_channels HTTP error") + return ListChannelsResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord list_channels failed") + return ListChannelsResponse( + success=False, error=f"Failed to list Discord channels: {str(e)}" + ) + + +@tool() +async def discord_create_thread( + channel_id: str, + name: str, + message_id: str | None = None, + auto_archive_duration: int = 1440, +) -> DiscordCreateThreadResponse: + """Create a new thread in a Discord channel. + + When message_id is provided, creates a thread attached to that message + (uses POST /channels/{channel_id}/messages/{message_id}/threads). + Otherwise creates a standalone thread without an associated message + (uses POST /channels/{channel_id}/threads). + + Args: + channel_id: The ID of the channel to create the thread in. + name: The name of the thread (1-100 characters). + message_id: Optional message ID to start the thread from. + When omitted, creates a standalone thread. + auto_archive_duration: Duration in minutes before the thread is + automatically archived. Must be one of: + 60 (1 hour), 1440 (1 day, default), 4320 (3 days), + or 10080 (7 days, requires server boost level 2). + + Returns: + Response indicating success with the new thread details, or an error. + """ + try: + headers = _get_headers() + if headers is None: + return DiscordCreateThreadResponse( + success=False, + error="Discord not configured. Set DISCORD_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "name": name, + "auto_archive_duration": auto_archive_duration, + } + + if message_id: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads" + else: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/threads" + # Standalone threads require a type; 11 = public thread + payload["type"] = 11 + + logger.info( + "Creating Discord thread name=%s in channel=%s message=%s", + name, + channel_id, + message_id, + ) + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + thread_type_map = { + 10: "news", + 11: "public", + 12: "private", + } + + return DiscordCreateThreadResponse( + success=True, + data=DiscordThreadCreatedData( + thread_id=data["id"], + name=data.get("name", name), + channel_id=channel_id, + type=thread_type_map.get(data.get("type"), "public"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Discord create_thread HTTP error") + return DiscordCreateThreadResponse( + success=False, + error=f"Discord API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Discord create_thread failed") + return DiscordCreateThreadResponse( + success=False, error=f"Failed to create Discord thread: {str(e)}" + ) diff --git a/src/tools/messaging/email_smtp.py b/src/tools/messaging/email_smtp.py new file mode 100644 index 0000000..e71b936 --- /dev/null +++ b/src/tools/messaging/email_smtp.py @@ -0,0 +1,137 @@ +"""SMTP email tool for sending emails via a configured SMTP server. + +Supports plain-text and HTML emails, CC/BCC recipients, and +auto-negotiation of TLS (STARTTLS on port 587, implicit SSL on port 465). +Requires SMTP_HOST, SMTP_USERNAME, and SMTP_PASSWORD environment variables. +""" + +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + EmailSentData, + SendEmailResponse, +) + +logger = logging.getLogger("humcp.tools.email_smtp") + + +@tool() +async def send_email( + to: str, + subject: str, + body: str, + from_addr: str | None = None, + html_body: str | None = None, + cc: str | None = None, + bcc: str | None = None, +) -> SendEmailResponse: + """Send an email via SMTP. + + Connects to the SMTP server configured through environment variables. + Uses STARTTLS on port 587 (default) or implicit SSL on port 465. + + Args: + to: Recipient email address. Multiple recipients can be separated by commas. + subject: Email subject line. + body: Plain-text email body. Used as fallback when html_body is also provided. + from_addr: Sender email address. Defaults to the SMTP_USERNAME env var. + html_body: Optional HTML body. When provided, the email is sent as + multipart/alternative with both plain-text and HTML parts. + cc: CC recipients, separated by commas. Visible to all recipients. + bcc: BCC recipients, separated by commas. Hidden from other recipients. + + Returns: + Response indicating success with email details, or an error. + """ + try: + smtp_host = await resolve_credential("SMTP_HOST") + smtp_port_str = await resolve_credential("SMTP_PORT") or "587" + smtp_username = await resolve_credential("SMTP_USERNAME") + smtp_password = await resolve_credential("SMTP_PASSWORD") + + if not smtp_host: + return SendEmailResponse( + success=False, + error="SMTP not configured. Set SMTP_HOST environment variable.", + ) + if not smtp_username or not smtp_password: + return SendEmailResponse( + success=False, + error="SMTP credentials not configured. Set SMTP_USERNAME and SMTP_PASSWORD environment variables.", + ) + + try: + smtp_port = int(smtp_port_str) + except ValueError: + return SendEmailResponse( + success=False, + error=f"Invalid SMTP_PORT value: {smtp_port_str}", + ) + + sender = from_addr or smtp_username + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = to + + if cc: + msg["Cc"] = cc + if bcc: + msg["Bcc"] = bcc + + msg.set_content(body) + + if html_body: + msg.add_alternative(html_body, subtype="html") + + # Build the full recipient list for the SMTP envelope + all_recipients = [addr.strip() for addr in to.split(",")] + if cc: + all_recipients.extend(addr.strip() for addr in cc.split(",")) + if bcc: + all_recipients.extend(addr.strip() for addr in bcc.split(",")) + + logger.info( + "Sending email to=%s subject=%s via %s:%d", + to, + subject, + smtp_host, + smtp_port, + ) + + if smtp_port == 465: + with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: + server.login(smtp_username, smtp_password) + server.send_message(msg) + else: + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_username, smtp_password) + server.send_message(msg) + + logger.info("Email sent successfully to=%s", to) + return SendEmailResponse( + success=True, + data=EmailSentData( + to=to, + subject=subject, + ), + ) + except smtplib.SMTPAuthenticationError as e: + logger.exception("SMTP authentication failed") + return SendEmailResponse( + success=False, error=f"SMTP authentication failed: {str(e)}" + ) + except smtplib.SMTPException as e: + logger.exception("SMTP error sending email") + return SendEmailResponse(success=False, error=f"SMTP error: {str(e)}") + except Exception as e: + logger.exception("Failed to send email") + return SendEmailResponse(success=False, error=f"Failed to send email: {str(e)}") diff --git a/src/tools/messaging/resend.py b/src/tools/messaging/resend.py new file mode 100644 index 0000000..596bbe7 --- /dev/null +++ b/src/tools/messaging/resend.py @@ -0,0 +1,198 @@ +"""Resend email tools for sending emails via the Resend API. + +Supports single and batch email sending, and retrieving sent email details. +See https://resend.com/docs/api-reference for full API documentation. +Requires the RESEND_API_KEY environment variable. +""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + BatchEmailResultItem, + BatchEmailSentData, + BatchEmailSentResponse, + EmailSentData, + SendEmailResponse, +) + +try: + import resend as resend_lib +except ImportError as err: + raise ImportError( + "resend is required for Resend tools. Install with: pip install resend" + ) from err + +logger = logging.getLogger("humcp.tools.resend") + + +def _configure_api_key(api_key: str) -> None: + """Set the Resend API key on the library.""" + resend_lib.api_key = api_key + + +@tool() +async def resend_send_email( + to: str, + subject: str, + html: str, + from_addr: str | None = None, + text: str | None = None, + cc: str | None = None, + bcc: str | None = None, + reply_to: str | None = None, +) -> SendEmailResponse: + """Send an email using the Resend API. + + Uses the POST /emails endpoint. Resend requires a verified sender domain. + + Args: + to: Recipient email address. For multiple recipients, separate with commas. + subject: Email subject line. + html: HTML body of the email. + from_addr: Sender email address (e.g., "Name "). + Required by Resend; must use a verified domain. + text: Optional plain-text body (fallback for email clients that + do not render HTML). + cc: CC recipients, separated by commas. + bcc: BCC recipients, separated by commas. + reply_to: Reply-to email address. + + Returns: + Response indicating success with email ID and details, or an error. + """ + try: + api_key = await resolve_credential("RESEND_API_KEY") + if not api_key: + return SendEmailResponse( + success=False, + error="Resend not configured. Set RESEND_API_KEY environment variable.", + ) + + _configure_api_key(api_key) + if not from_addr: + return SendEmailResponse( + success=False, + error="from_addr is required for Resend emails. Provide a verified sender address.", + ) + + to_list = [addr.strip() for addr in to.split(",")] + + logger.info("Sending email via Resend to=%s subject=%s", to, subject) + + params: dict = { + "from": from_addr, + "to": to_list, + "subject": subject, + "html": html, + } + if text: + params["text"] = text + if cc: + params["cc"] = [addr.strip() for addr in cc.split(",")] + if bcc: + params["bcc"] = [addr.strip() for addr in bcc.split(",")] + if reply_to: + params["reply_to"] = reply_to + + result = resend_lib.Emails.send(params) # type: ignore[arg-type] + + message_id = None + if isinstance(result, dict): + message_id = result.get("id") + + logger.info("Resend email sent successfully to=%s", to) + return SendEmailResponse( + success=True, + data=EmailSentData( + message_id=message_id, + to=to, + subject=subject, + ), + ) + except Exception as e: + logger.exception("Resend send_email failed") + return SendEmailResponse( + success=False, error=f"Failed to send email via Resend: {str(e)}" + ) + + +@tool() +async def resend_send_batch_emails( + from_addr: str, + to_addresses: str, + subject: str, + html: str, +) -> BatchEmailSentResponse: + """Send a batch of emails using the Resend batch API. + + Uses the POST /emails/batch endpoint. Sends the same content to multiple + recipients as individual emails (up to 100 per call). Each recipient + receives their own email and cannot see other recipients. + + Args: + from_addr: Sender email address (must use a verified domain). + to_addresses: Comma-separated list of recipient email addresses (max 100). + subject: Email subject line (same for all recipients). + html: HTML body of the email (same for all recipients). + + Returns: + Response indicating success with individual email IDs, or an error. + """ + try: + api_key = await resolve_credential("RESEND_API_KEY") + if not api_key: + return BatchEmailSentResponse( + success=False, + error="Resend not configured. Set RESEND_API_KEY environment variable.", + ) + + _configure_api_key(api_key) + recipients = [addr.strip() for addr in to_addresses.split(",") if addr.strip()] + if not recipients: + return BatchEmailSentResponse( + success=False, + error="No valid recipient addresses provided.", + ) + if len(recipients) > 100: + return BatchEmailSentResponse( + success=False, + error="Resend batch API supports a maximum of 100 recipients per call.", + ) + + logger.info("Sending batch email via Resend to %d recipients", len(recipients)) + + batch_params = [ + { + "from": from_addr, + "to": [recipient], + "subject": subject, + "html": html, + } + for recipient in recipients + ] + + result = resend_lib.Batch.send(batch_params) # type: ignore[arg-type] + + items: list[BatchEmailResultItem] = [] + if isinstance(result, dict): + for item in result.get("data", []): + items.append(BatchEmailResultItem(id=item.get("id"))) + elif isinstance(result, list): + for item in result: + item_id = item.get("id") if isinstance(item, dict) else None + items.append(BatchEmailResultItem(id=item_id)) + + logger.info("Batch email sent successfully, %d emails", len(items)) + return BatchEmailSentResponse( + success=True, + data=BatchEmailSentData(results=items, count=len(items)), + ) + except Exception as e: + logger.exception("Resend send_batch_emails failed") + return BatchEmailSentResponse( + success=False, error=f"Failed to send batch emails via Resend: {str(e)}" + ) diff --git a/src/tools/messaging/schemas.py b/src/tools/messaging/schemas.py new file mode 100644 index 0000000..1fcee9f --- /dev/null +++ b/src/tools/messaging/schemas.py @@ -0,0 +1,624 @@ +"""Pydantic output schemas for messaging tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Shared Messaging Schemas +# ============================================================================= + + +class MessageSentData(BaseModel): + """Data returned after sending a message.""" + + message_id: str | None = Field(default=None, description="ID of the sent message") + channel: str | None = Field( + default=None, description="Channel or recipient the message was sent to" + ) + timestamp: str | None = Field( + default=None, description="Timestamp of the sent message" + ) + + +class SendMessageResponse(ToolResponse[MessageSentData]): + """Response schema for message-sending tools.""" + + pass + + +# ============================================================================= +# Channel / Room Listing Schemas +# ============================================================================= + + +class ChannelInfo(BaseModel): + """Information about a channel or room.""" + + id: str = Field(..., description="Channel or room ID") + name: str = Field(..., description="Channel or room name") + type: str | None = Field( + default=None, description="Channel type (e.g., public, private, direct)" + ) + is_member: bool | None = Field( + default=None, description="Whether the bot is a member" + ) + + +class ListChannelsData(BaseModel): + """Data returned when listing channels.""" + + channels: list[ChannelInfo] = Field(..., description="List of channels") + count: int = Field(..., description="Number of channels returned") + + +class ListChannelsResponse(ToolResponse[ListChannelsData]): + """Response schema for channel-listing tools.""" + + pass + + +# ============================================================================= +# Message History Schemas +# ============================================================================= + + +class MessageInfo(BaseModel): + """A single message from channel history or search results.""" + + text: str = Field(..., description="Message text content") + user: str | None = Field(default=None, description="User who sent the message") + timestamp: str | None = Field(default=None, description="Message timestamp") + channel: str | None = Field( + default=None, description="Channel the message belongs to" + ) + permalink: str | None = Field(default=None, description="Permalink to the message") + thread_ts: str | None = Field( + default=None, description="Thread timestamp if message is in a thread" + ) + + +class ChannelHistoryData(BaseModel): + """Data returned when fetching channel history.""" + + messages: list[MessageInfo] = Field(..., description="List of messages") + count: int = Field(..., description="Number of messages returned") + channel: str = Field(..., description="Channel ID the history was fetched from") + + +class ChannelHistoryResponse(ToolResponse[ChannelHistoryData]): + """Response schema for channel history tools.""" + + pass + + +# ============================================================================= +# Search Messages Schemas +# ============================================================================= + + +class SearchMessagesData(BaseModel): + """Data returned when searching messages.""" + + messages: list[MessageInfo] = Field(..., description="List of matching messages") + count: int = Field(..., description="Number of matching messages") + query: str = Field(..., description="The search query that was executed") + + +class SearchMessagesResponse(ToolResponse[SearchMessagesData]): + """Response schema for message search tools.""" + + pass + + +# ============================================================================= +# Email Schemas +# ============================================================================= + + +class EmailSentData(BaseModel): + """Data returned after sending an email.""" + + message_id: str | None = Field(default=None, description="ID of the sent email") + to: str = Field(..., description="Recipient email address") + subject: str = Field(..., description="Email subject") + + +class SendEmailResponse(ToolResponse[EmailSentData]): + """Response schema for email-sending tools.""" + + pass + + +# ============================================================================= +# Batch Email Schemas (Resend) +# ============================================================================= + + +class BatchEmailResultItem(BaseModel): + """Result for a single email in a batch send.""" + + id: str | None = Field(None, description="ID of the sent email") + + +class BatchEmailSentData(BaseModel): + """Data returned after sending a batch of emails.""" + + results: list[BatchEmailResultItem] = Field(..., description="List of send results") + count: int = Field(..., description="Number of emails sent") + + +class BatchEmailSentResponse(ToolResponse[BatchEmailSentData]): + """Response schema for batch email send.""" + + pass + + +# ============================================================================= +# SMS Schemas +# ============================================================================= + + +class SmsSentData(BaseModel): + """Data returned after sending an SMS.""" + + message_sid: str | None = Field(None, description="SID of the sent SMS message") + to: str = Field(..., description="Recipient phone number") + from_number: str = Field(..., description="Sender phone number") + status: str | None = Field(None, description="Message delivery status") + + +class SendSmsResponse(ToolResponse[SmsSentData]): + """Response schema for SMS-sending tools.""" + + pass + + +# ============================================================================= +# SMS Status Schemas (Twilio) +# ============================================================================= + + +class SmsStatusData(BaseModel): + """Data returned when fetching SMS message status.""" + + message_sid: str = Field(..., description="SID of the SMS message") + to: str | None = Field(None, description="Recipient phone number") + from_number: str | None = Field(None, description="Sender phone number") + status: str | None = Field( + None, + description="Current message status (queued, sending, sent, delivered, failed, undelivered)", + ) + date_sent: str | None = Field(None, description="Date the message was sent") + error_code: int | None = Field(None, description="Error code if message failed") + error_message: str | None = Field( + None, description="Error message if message failed" + ) + + +class GetSmsStatusResponse(ToolResponse[SmsStatusData]): + """Response schema for getting SMS message status.""" + + pass + + +# ============================================================================= +# Voice Call Schemas (Twilio) +# ============================================================================= + + +class VoiceCallData(BaseModel): + """Data returned after initiating a voice call.""" + + call_sid: str | None = Field(None, description="SID of the initiated call") + to: str = Field(..., description="Recipient phone number") + from_number: str = Field(..., description="Caller phone number") + status: str | None = Field( + None, description="Call status (queued, ringing, in-progress, completed, etc.)" + ) + + +class MakeVoiceCallResponse(ToolResponse[VoiceCallData]): + """Response schema for making a voice call.""" + + pass + + +# ============================================================================= +# Telegram Update Schemas +# ============================================================================= + + +class TelegramUpdate(BaseModel): + """A single Telegram update.""" + + update_id: int = Field(..., description="Unique update identifier") + message_text: str | None = Field(None, description="Text content of the message") + chat_id: int | None = Field(None, description="Chat ID the update belongs to") + from_user: str | None = Field(None, description="Username of the sender") + date: int | None = Field(None, description="Unix timestamp of the message") + + +class TelegramUpdatesData(BaseModel): + """Data returned when fetching Telegram updates.""" + + updates: list[TelegramUpdate] = Field(..., description="List of updates") + count: int = Field(..., description="Number of updates returned") + + +class TelegramUpdatesResponse(ToolResponse[TelegramUpdatesData]): + """Response schema for Telegram get_updates tool.""" + + pass + + +# ============================================================================= +# Telegram Photo Schemas +# ============================================================================= + + +class TelegramPhotoSentData(BaseModel): + """Data returned after sending a photo via Telegram.""" + + message_id: str | None = Field(None, description="ID of the sent photo message") + chat_id: str | None = Field(None, description="Chat ID the photo was sent to") + + +class SendTelegramPhotoResponse(ToolResponse[TelegramPhotoSentData]): + """Response schema for sending a Telegram photo.""" + + pass + + +# ============================================================================= +# Telegram Edit Message Schemas +# ============================================================================= + + +class TelegramEditedMessageData(BaseModel): + """Data returned after editing a Telegram message.""" + + message_id: str | None = Field(None, description="ID of the edited message") + chat_id: str | None = Field(None, description="Chat ID of the edited message") + text: str | None = Field(None, description="New text of the edited message") + + +class EditTelegramMessageResponse(ToolResponse[TelegramEditedMessageData]): + """Response schema for editing a Telegram message.""" + + pass + + +# ============================================================================= +# Discord Reaction Schemas +# ============================================================================= + + +class ReactionAddedData(BaseModel): + """Data returned after adding a reaction.""" + + message_id: str = Field( + ..., description="ID of the message the reaction was added to" + ) + emoji: str = Field(..., description="The emoji that was added as a reaction") + channel_id: str = Field(..., description="Channel ID of the message") + + +class AddReactionResponse(ToolResponse[ReactionAddedData]): + """Response schema for adding a reaction.""" + + pass + + +# ============================================================================= +# Discord Get Messages Schemas +# ============================================================================= + + +class DiscordMessageInfo(BaseModel): + """A single Discord message.""" + + id: str = Field(..., description="Message ID") + content: str = Field(..., description="Message text content") + author: str | None = Field(None, description="Author username") + timestamp: str | None = Field(None, description="Message timestamp (ISO8601)") + channel_id: str | None = Field( + None, description="Channel the message was posted in" + ) + + +class GetMessagesData(BaseModel): + """Data returned when fetching Discord messages.""" + + messages: list[DiscordMessageInfo] = Field(..., description="List of messages") + count: int = Field(..., description="Number of messages returned") + channel_id: str = Field( + ..., description="Channel ID the messages were fetched from" + ) + + +class GetMessagesResponse(ToolResponse[GetMessagesData]): + """Response schema for fetching Discord messages.""" + + pass + + +# ============================================================================= +# Slack Reaction Schemas +# ============================================================================= + + +class SlackReactionData(BaseModel): + """Data returned after adding a Slack reaction.""" + + channel: str = Field(..., description="Channel ID") + timestamp: str = Field( + ..., description="Message timestamp the reaction was added to" + ) + reaction: str = Field(..., description="The reaction name that was added") + + +class SlackReactionResponse(ToolResponse[SlackReactionData]): + """Response schema for Slack reaction tools.""" + + pass + + +# ============================================================================= +# Slack Thread Reply Schemas +# ============================================================================= + + +class SlackThreadReplyData(BaseModel): + """Data returned when fetching thread replies.""" + + messages: list[MessageInfo] = Field( + ..., description="List of messages in the thread" + ) + count: int = Field(..., description="Number of messages in the thread") + channel: str = Field(..., description="Channel ID") + thread_ts: str = Field(..., description="Thread parent timestamp") + + +class SlackThreadReplyResponse(ToolResponse[SlackThreadReplyData]): + """Response schema for Slack thread reply tools.""" + + pass + + +# ============================================================================= +# Slack User Info Schemas +# ============================================================================= + + +class SlackUserInfo(BaseModel): + """Information about a Slack user.""" + + id: str = Field(..., description="User ID") + name: str = Field(..., description="Username") + real_name: str | None = Field(None, description="User's display name") + email: str | None = Field(None, description="User's email address") + is_bot: bool | None = Field(None, description="Whether the user is a bot") + is_admin: bool | None = Field(None, description="Whether the user is an admin") + + +class SlackUserInfoData(BaseModel): + """Data returned when fetching Slack user info.""" + + user: SlackUserInfo = Field(..., description="User information") + + +class SlackUserInfoResponse(ToolResponse[SlackUserInfoData]): + """Response schema for Slack user info tools.""" + + pass + + +# ============================================================================= +# Webex Room Schemas +# ============================================================================= + + +class WebexRoomInfo(BaseModel): + """Information about a Webex room.""" + + id: str = Field(..., description="Room ID") + title: str = Field(..., description="Room title") + type: str | None = Field(None, description="Room type (direct, group)") + is_locked: bool | None = Field(None, description="Whether the room is locked") + created: str | None = Field(None, description="Room creation timestamp (ISO8601)") + + +class ListRoomsData(BaseModel): + """Data returned when listing Webex rooms.""" + + rooms: list[WebexRoomInfo] = Field(..., description="List of rooms") + count: int = Field(..., description="Number of rooms returned") + + +class ListRoomsResponse(ToolResponse[ListRoomsData]): + """Response schema for Webex room-listing tools.""" + + pass + + +# ============================================================================= +# Webex Create Room Schemas +# ============================================================================= + + +class WebexRoomCreatedData(BaseModel): + """Data returned after creating a Webex room.""" + + id: str = Field(..., description="ID of the newly created room") + title: str = Field(..., description="Title of the room") + type: str | None = Field(None, description="Room type (direct, group)") + created: str | None = Field(None, description="Room creation timestamp (ISO8601)") + + +class CreateRoomResponse(ToolResponse[WebexRoomCreatedData]): + """Response schema for creating a Webex room.""" + + pass + + +# ============================================================================= +# Webex Room Details Schemas +# ============================================================================= + + +class WebexRoomDetailsData(BaseModel): + """Detailed data about a Webex room.""" + + id: str = Field(..., description="Room ID") + title: str = Field(..., description="Room title") + type: str | None = Field(None, description="Room type (direct, group)") + is_locked: bool | None = Field(None, description="Whether the room is locked") + team_id: str | None = Field(None, description="Team ID if associated with a team") + created: str | None = Field(None, description="Room creation timestamp (ISO8601)") + creator_id: str | None = Field(None, description="ID of the room creator") + + +class GetRoomDetailsResponse(ToolResponse[WebexRoomDetailsData]): + """Response schema for getting Webex room details.""" + + pass + + +# ============================================================================= +# WhatsApp Template Message Schemas +# ============================================================================= + + +class WhatsAppTemplateSentData(BaseModel): + """Data returned after sending a WhatsApp template message.""" + + message_id: str | None = Field(None, description="ID of the sent template message") + to: str = Field(..., description="Recipient phone number") + template_name: str = Field(..., description="Name of the template used") + + +class SendWhatsAppTemplateResponse(ToolResponse[WhatsAppTemplateSentData]): + """Response schema for sending a WhatsApp template message.""" + + pass + + +# ============================================================================= +# WhatsApp Media Message Schemas +# ============================================================================= + + +class WhatsAppMediaSentData(BaseModel): + """Data returned after sending a WhatsApp media message.""" + + message_id: str | None = Field(None, description="ID of the sent media message") + to: str = Field(..., description="Recipient phone number") + media_type: str = Field( + ..., description="Type of media sent (image, document, video, audio)" + ) + + +class SendWhatsAppMediaResponse(ToolResponse[WhatsAppMediaSentData]): + """Response schema for sending a WhatsApp media message.""" + + pass + + +# ============================================================================= +# Slack Set Channel Topic Schemas +# ============================================================================= + + +class SlackChannelTopicData(BaseModel): + """Data returned after setting a Slack channel topic.""" + + channel: str = Field(..., description="Channel ID the topic was set on") + topic: str = Field(..., description="The new topic that was set") + + +class SlackSetChannelTopicResponse(ToolResponse[SlackChannelTopicData]): + """Response schema for setting a Slack channel topic.""" + + pass + + +# ============================================================================= +# Discord Create Thread Schemas +# ============================================================================= + + +class DiscordThreadCreatedData(BaseModel): + """Data returned after creating a Discord thread.""" + + thread_id: str = Field(..., description="ID of the newly created thread") + name: str = Field(..., description="Name of the thread") + channel_id: str = Field( + ..., description="Parent channel ID the thread was created in" + ) + type: str | None = Field(None, description="Thread type (e.g., public, private)") + + +class DiscordCreateThreadResponse(ToolResponse[DiscordThreadCreatedData]): + """Response schema for creating a Discord thread.""" + + pass + + +# ============================================================================= +# Telegram Pin Message Schemas +# ============================================================================= + + +class TelegramPinnedMessageData(BaseModel): + """Data returned after pinning a Telegram message.""" + + chat_id: str = Field(..., description="Chat ID where the message was pinned") + message_id: int = Field(..., description="ID of the pinned message") + + +class TelegramPinMessageResponse(ToolResponse[TelegramPinnedMessageData]): + """Response schema for pinning a Telegram message.""" + + pass + + +# ============================================================================= +# Twilio WhatsApp Schemas +# ============================================================================= + + +class TwilioWhatsAppSentData(BaseModel): + """Data returned after sending a WhatsApp message via Twilio.""" + + message_sid: str | None = Field( + None, description="SID of the sent WhatsApp message" + ) + to: str = Field(..., description="Recipient WhatsApp number") + from_number: str = Field(..., description="Sender WhatsApp number (Twilio)") + status: str | None = Field(None, description="Message delivery status") + + +class TwilioSendWhatsAppResponse(ToolResponse[TwilioWhatsAppSentData]): + """Response schema for sending a WhatsApp message via Twilio.""" + + pass + + +# ============================================================================= +# Generic Operation Success Schema +# ============================================================================= + + +class OperationSuccessData(BaseModel): + """Data returned for a generic successful operation.""" + + message: str = Field( + ..., description="Success message describing the completed operation" + ) + + +class OperationSuccessResponse(ToolResponse[OperationSuccessData]): + """Response schema for generic success operations.""" + + pass diff --git a/src/tools/messaging/slack.py b/src/tools/messaging/slack.py new file mode 100644 index 0000000..5b85e75 --- /dev/null +++ b/src/tools/messaging/slack.py @@ -0,0 +1,592 @@ +"""Slack messaging tools for sending messages, managing reactions, threads, and channels. + +Uses the Slack Web API via the slack_sdk Python package. +See https://docs.slack.dev/reference/methods/ for full API reference. +Requires the SLACK_TOKEN environment variable (Bot User OAuth Token). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + ChannelHistoryData, + ChannelHistoryResponse, + ChannelInfo, + ListChannelsData, + ListChannelsResponse, + MessageInfo, + MessageSentData, + SearchMessagesData, + SearchMessagesResponse, + SendMessageResponse, + SlackChannelTopicData, + SlackReactionData, + SlackReactionResponse, + SlackSetChannelTopicResponse, + SlackThreadReplyData, + SlackThreadReplyResponse, + SlackUserInfo, + SlackUserInfoData, + SlackUserInfoResponse, +) + +try: + from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError +except ImportError as err: + raise ImportError( + "slack_sdk is required for Slack tools. Install with: pip install slack-sdk" + ) from err + +logger = logging.getLogger("humcp.tools.slack") + + +def _get_client() -> WebClient | None: + """Create a Slack WebClient from the environment token.""" + token = os.getenv("SLACK_TOKEN") + if not token: + return None + return WebClient(token=token) + + +@tool() +async def slack_send_message( + channel: str, text: str, thread_ts: str | None = None +) -> SendMessageResponse: + """Send a message to a Slack channel or thread. + + Uses the chat.postMessage API method. Supports Slack mrkdwn formatting. + To reply in a thread, provide the thread_ts of the parent message. + + Args: + channel: The channel ID or name to send the message to. + text: The text of the message. Supports Slack mrkdwn formatting + (e.g., *bold*, _italic_, ~strike~, `code`, ```code block```). + thread_ts: Optional timestamp of the parent message to reply in a thread. + When set, the message is posted as a threaded reply. + + Returns: + Response indicating success with message details, or an error. + """ + try: + client = _get_client() + if client is None: + return SendMessageResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info( + "Sending Slack message to channel=%s thread_ts=%s", channel, thread_ts + ) + kwargs: dict = {"channel": channel, "text": text, "mrkdwn": True} + if thread_ts: + kwargs["thread_ts"] = thread_ts + + response = client.chat_postMessage(**kwargs) + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=response.get("ts"), + channel=response.get("channel"), + timestamp=response.get("ts"), + ), + ) + except SlackApiError as e: + logger.exception("Slack send_message failed") + return SendMessageResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Slack message: {str(e)}" + ) + + +@tool() +async def slack_add_reaction( + channel: str, timestamp: str, name: str +) -> SlackReactionResponse: + """Add an emoji reaction to a Slack message. + + Uses the reactions.add API method. The bot must be in the channel. + + Args: + channel: The channel ID containing the message. + timestamp: The timestamp (ts) of the message to react to. + name: The name of the emoji reaction without colons (e.g., "thumbsup", + "heart", "white_check_mark"). + + Returns: + Response indicating success, or an error. + """ + try: + client = _get_client() + if client is None: + return SlackReactionResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info( + "Adding Slack reaction name=%s to channel=%s ts=%s", + name, + channel, + timestamp, + ) + client.reactions_add(channel=channel, timestamp=timestamp, name=name) + + return SlackReactionResponse( + success=True, + data=SlackReactionData( + channel=channel, + timestamp=timestamp, + reaction=name, + ), + ) + except SlackApiError as e: + logger.exception("Slack add_reaction failed") + return SlackReactionResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack add_reaction failed") + return SlackReactionResponse( + success=False, error=f"Failed to add Slack reaction: {str(e)}" + ) + + +@tool() +async def slack_get_thread_replies( + channel: str, thread_ts: str, limit: int = 50 +) -> SlackThreadReplyResponse: + """Fetch replies in a Slack message thread. + + Uses the conversations.replies API method. Returns all messages in + the thread, including the parent message. + + Args: + channel: The channel ID containing the thread. + thread_ts: The timestamp (ts) of the parent message that started the thread. + limit: Maximum number of replies to retrieve (default 50, max 200). + + Returns: + Response containing the thread messages. + """ + try: + client = _get_client() + if client is None: + return SlackThreadReplyResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return SlackThreadReplyResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 200) + logger.info( + "Fetching Slack thread replies channel=%s thread_ts=%s limit=%d", + channel, + thread_ts, + capped_limit, + ) + response = client.conversations_replies( + channel=channel, ts=thread_ts, limit=capped_limit + ) + + msg_list: list[dict[str, Any]] = response.get("messages", []) + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=channel, + thread_ts=msg.get("thread_ts"), + ) + for msg in msg_list + ] + + return SlackThreadReplyResponse( + success=True, + data=SlackThreadReplyData( + messages=messages, + count=len(messages), + channel=channel, + thread_ts=thread_ts, + ), + ) + except SlackApiError as e: + logger.exception("Slack get_thread_replies failed") + return SlackThreadReplyResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_thread_replies failed") + return SlackThreadReplyResponse( + success=False, error=f"Failed to get Slack thread replies: {str(e)}" + ) + + +@tool() +async def slack_get_user_info(user_id: str) -> SlackUserInfoResponse: + """Get profile information about a Slack user. + + Uses the users.info API method. + + Args: + user_id: The Slack user ID (e.g., "U0123456789"). + + Returns: + Response containing the user's profile information. + """ + try: + client = _get_client() + if client is None: + return SlackUserInfoResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info("Fetching Slack user info for user_id=%s", user_id) + response = client.users_info(user=user_id) + + user_data: dict[str, Any] = response.get("user", {}) + profile: dict[str, Any] = user_data.get("profile", {}) + + return SlackUserInfoResponse( + success=True, + data=SlackUserInfoData( + user=SlackUserInfo( + id=user_data.get("id", user_id), + name=user_data.get("name", ""), + real_name=profile.get("real_name"), + email=profile.get("email"), + is_bot=user_data.get("is_bot"), + is_admin=user_data.get("is_admin"), + ), + ), + ) + except SlackApiError as e: + logger.exception("Slack get_user_info failed") + return SlackUserInfoResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_user_info failed") + return SlackUserInfoResponse( + success=False, error=f"Failed to get Slack user info: {str(e)}" + ) + + +@tool() +async def slack_list_channels() -> ListChannelsResponse: + """List all channels in the Slack workspace. + + Uses the conversations.list API method. Returns both public and private + channels the bot has access to. + + Returns: + Response containing a list of channels with their IDs, names, and types. + """ + try: + client = _get_client() + if client is None: + return ListChannelsResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + logger.info("Listing Slack channels") + response = client.conversations_list() + + ch_list: list[dict[str, Any]] = response.get("channels", []) + channels = [ + ChannelInfo( + id=ch["id"], + name=ch["name"], + type="public" if not ch.get("is_private") else "private", + is_member=ch.get("is_member"), + ) + for ch in ch_list + ] + + return ListChannelsResponse( + success=True, + data=ListChannelsData(channels=channels, count=len(channels)), + ) + except SlackApiError as e: + logger.exception("Slack list_channels failed") + return ListChannelsResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack list_channels failed") + return ListChannelsResponse( + success=False, error=f"Failed to list Slack channels: {str(e)}" + ) + + +@tool() +async def slack_get_channel_history( + channel: str, limit: int = 100 +) -> ChannelHistoryResponse: + """Get the message history of a Slack channel. + + Uses the conversations.history API method. + + Args: + channel: The channel ID to fetch history from. + limit: The maximum number of messages to fetch (default 100, max 1000). + + Returns: + Response containing the channel's message history. + """ + try: + client = _get_client() + if client is None: + return ChannelHistoryResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return ChannelHistoryResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 1000) + logger.info( + "Fetching Slack channel history channel=%s limit=%d", channel, capped_limit + ) + response = client.conversations_history(channel=channel, limit=capped_limit) + + hist_list: list[dict[str, Any]] = response.get("messages", []) + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=channel, + thread_ts=msg.get("thread_ts"), + ) + for msg in hist_list + ] + + return ChannelHistoryResponse( + success=True, + data=ChannelHistoryData( + messages=messages, count=len(messages), channel=channel + ), + ) + except SlackApiError as e: + logger.exception("Slack get_channel_history failed") + return ChannelHistoryResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack get_channel_history failed") + return ChannelHistoryResponse( + success=False, error=f"Failed to get channel history: {str(e)}" + ) + + +@tool() +async def slack_search_messages(query: str, limit: int = 20) -> SearchMessagesResponse: + """Search messages across the Slack workspace. + + Uses the search.messages API method. Requires a user token with + search:read scope (bot tokens cannot use search). + + Args: + query: The search query. Supports modifiers like from:@user, in:#channel, + has:link, has:reaction, before:YYYY-MM-DD, after:YYYY-MM-DD. + limit: The maximum number of results to return (default 20, max 100). + + Returns: + Response containing matching messages. + """ + try: + client = _get_client() + if client is None: + return SearchMessagesResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if limit < 1: + return SearchMessagesResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 100) + logger.info( + "Searching Slack messages query_length=%d limit=%d", + len(query), + capped_limit, + ) + response = client.search_messages(query=query, count=capped_limit) + + matches: list[dict[str, Any]] = response.get("messages", {}).get("matches", []) # type: ignore[call-overload] + messages = [ + MessageInfo( + text=msg.get("text", ""), + user=msg.get("user", "unknown"), + timestamp=msg.get("ts", ""), + channel=msg.get("channel", {}).get("name", ""), + permalink=msg.get("permalink", ""), + ) + for msg in matches + ] + + return SearchMessagesResponse( + success=True, + data=SearchMessagesData( + messages=messages, count=len(messages), query=query + ), + ) + except SlackApiError as e: + logger.exception("Slack search_messages failed") + return SearchMessagesResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack search_messages failed") + return SearchMessagesResponse( + success=False, error=f"Failed to search Slack messages: {str(e)}" + ) + + +@tool() +async def slack_reply_to_thread( + channel: str, thread_ts: str, text: str, broadcast: bool = False +) -> SendMessageResponse: + """Reply to a specific message thread in a Slack channel. + + Uses the chat.postMessage API method with thread_ts to post a threaded + reply. This is a convenience wrapper that ensures the reply goes into an + existing thread rather than starting a new conversation. + + Args: + channel: The channel ID containing the parent message thread. + thread_ts: The timestamp (ts) of the parent message to reply to. + This is the ts value from the original message that started + the thread. + text: The text of the reply. Supports Slack mrkdwn formatting + (e.g., *bold*, _italic_, ~strike~, `code`, ```code block```). + broadcast: If true, the reply will also be posted to the channel as a + regular message, visible outside the thread. Known as + "Also send to channel" in the Slack UI. + + Returns: + Response indicating success with message details, or an error. + """ + try: + client = _get_client() + if client is None: + return SendMessageResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if not thread_ts or not thread_ts.strip(): + return SendMessageResponse( + success=False, + error="thread_ts is required to reply in a thread.", + ) + + logger.info( + "Replying to Slack thread channel=%s thread_ts=%s broadcast=%s", + channel, + thread_ts, + broadcast, + ) + kwargs: dict = { + "channel": channel, + "text": text, + "thread_ts": thread_ts, + "mrkdwn": True, + } + if broadcast: + kwargs["reply_broadcast"] = True + + response = client.chat_postMessage(**kwargs) + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=response.get("ts"), + channel=response.get("channel"), + timestamp=response.get("ts"), + ), + ) + except SlackApiError as e: + logger.exception("Slack reply_to_thread failed") + return SendMessageResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack reply_to_thread failed") + return SendMessageResponse( + success=False, error=f"Failed to reply to Slack thread: {str(e)}" + ) + + +@tool() +async def slack_set_channel_topic( + channel: str, topic: str +) -> SlackSetChannelTopicResponse: + """Set the topic of a Slack channel. + + Uses the conversations.setTopic API method. The bot must be a member of + the channel and have the necessary permissions to change the topic. + + Args: + channel: The channel ID to set the topic for. + topic: The new topic text for the channel (up to 250 characters). + + Returns: + Response indicating success with the updated topic, or an error. + """ + try: + client = _get_client() + if client is None: + return SlackSetChannelTopicResponse( + success=False, + error="Slack not configured. Set SLACK_TOKEN environment variable.", + ) + + if len(topic) > 250: + return SlackSetChannelTopicResponse( + success=False, + error="Topic must be 250 characters or fewer.", + ) + + logger.info("Setting Slack channel topic channel=%s", channel) + response = client.conversations_setTopic(channel=channel, topic=topic) + + return SlackSetChannelTopicResponse( + success=True, + data=SlackChannelTopicData( + channel=response.get("channel", {}).get("id", channel), # type: ignore[call-overload] + topic=response.get("channel", {}).get("topic", {}).get("value", topic), # type: ignore[call-overload] + ), + ) + except SlackApiError as e: + logger.exception("Slack set_channel_topic failed") + return SlackSetChannelTopicResponse( + success=False, error=f"Slack API error: {e.response['error']}" + ) + except Exception as e: + logger.exception("Slack set_channel_topic failed") + return SlackSetChannelTopicResponse( + success=False, error=f"Failed to set Slack channel topic: {str(e)}" + ) diff --git a/src/tools/messaging/telegram.py b/src/tools/messaging/telegram.py new file mode 100644 index 0000000..6c919ec --- /dev/null +++ b/src/tools/messaging/telegram.py @@ -0,0 +1,436 @@ +"""Telegram Bot API tools for sending messages, photos, and managing updates. + +Uses the Telegram Bot API (https://core.telegram.org/bots/api). +Requires the TELEGRAM_BOT_TOKEN environment variable. +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + EditTelegramMessageResponse, + MessageSentData, + SendMessageResponse, + SendTelegramPhotoResponse, + TelegramEditedMessageData, + TelegramPhotoSentData, + TelegramPinMessageResponse, + TelegramPinnedMessageData, + TelegramUpdate, + TelegramUpdatesData, + TelegramUpdatesResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Telegram tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.telegram") + +TELEGRAM_API_BASE = "https://api.telegram.org" + + +def _get_bot_url() -> str | None: + """Build the Telegram Bot API base URL from the environment token.""" + token = os.getenv("TELEGRAM_BOT_TOKEN") + if not token: + return None + return f"{TELEGRAM_API_BASE}/bot{token}" + + +@tool() +async def telegram_send_message( + chat_id: str, + text: str, + parse_mode: str | None = None, + disable_notification: bool = False, + reply_to_message_id: int | None = None, +) -> SendMessageResponse: + """Send a text message to a Telegram chat. + + Uses the sendMessage Bot API method. + + Args: + chat_id: The unique identifier for the target chat or username of the + target channel (in the format @channelusername). + text: The text of the message to send (1-4096 characters). + parse_mode: Optional formatting mode. One of "MarkdownV2", "HTML", or + "Markdown" (legacy). When set, special characters must be + escaped according to the chosen mode. + disable_notification: If true, the message is sent silently and users + receive a notification with no sound. + reply_to_message_id: Optional message ID to reply to, making this + message a reply in the conversation. + + Returns: + Response indicating success with message details, or an error. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return SendMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = {"chat_id": chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + if disable_notification: + payload["disable_notification"] = True + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id + + logger.info("Sending Telegram message to chat_id=%s", chat_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/sendMessage", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return SendMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=str(result.get("message_id", "")), + channel=str(result.get("chat", {}).get("id", "")), + timestamp=str(result.get("date", "")), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Telegram message: {str(e)}" + ) + + +@tool() +async def telegram_send_photo( + chat_id: str, + photo_url: str, + caption: str | None = None, + parse_mode: str | None = None, +) -> SendTelegramPhotoResponse: + """Send a photo to a Telegram chat via URL. + + Uses the sendPhoto Bot API method. The photo must be accessible via a + public URL. For file uploads, use the Telegram file upload endpoint instead. + + Args: + chat_id: The unique identifier for the target chat or username of the + target channel (in the format @channelusername). + photo_url: URL of the photo to send. Telegram will download the image + from this URL. Must be a direct link to an image file + (JPEG, PNG, etc.) and less than 10 MB. + caption: Optional caption for the photo (0-1024 characters). + parse_mode: Optional formatting mode for the caption. One of + "MarkdownV2", "HTML", or "Markdown" (legacy). + + Returns: + Response indicating success with the sent photo message details. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return SendTelegramPhotoResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = {"chat_id": chat_id, "photo": photo_url} + if caption: + payload["caption"] = caption + if parse_mode: + payload["parse_mode"] = parse_mode + + logger.info("Sending Telegram photo to chat_id=%s", chat_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/sendPhoto", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return SendTelegramPhotoResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return SendTelegramPhotoResponse( + success=True, + data=TelegramPhotoSentData( + message_id=str(result.get("message_id", "")), + chat_id=str(result.get("chat", {}).get("id", "")), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram send_photo HTTP error") + return SendTelegramPhotoResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram send_photo failed") + return SendTelegramPhotoResponse( + success=False, error=f"Failed to send Telegram photo: {str(e)}" + ) + + +@tool() +async def telegram_edit_message( + chat_id: str, + message_id: int, + text: str, + parse_mode: str | None = None, +) -> EditTelegramMessageResponse: + """Edit the text of a previously sent Telegram message. + + Uses the editMessageText Bot API method. Can only edit messages sent by the + bot itself (not messages from other users). + + Args: + chat_id: The unique identifier for the chat containing the message. + message_id: The ID of the message to edit. + text: The new text of the message (1-4096 characters). + parse_mode: Optional formatting mode. One of "MarkdownV2", "HTML", + or "Markdown" (legacy). + + Returns: + Response indicating success with the edited message details. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return EditTelegramMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + if parse_mode: + payload["parse_mode"] = parse_mode + + logger.info( + "Editing Telegram message chat_id=%s message_id=%d", chat_id, message_id + ) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/editMessageText", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return EditTelegramMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + result = data.get("result", {}) + return EditTelegramMessageResponse( + success=True, + data=TelegramEditedMessageData( + message_id=str(result.get("message_id", "")), + chat_id=str(result.get("chat", {}).get("id", "")), + text=result.get("text"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram edit_message HTTP error") + return EditTelegramMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram edit_message failed") + return EditTelegramMessageResponse( + success=False, error=f"Failed to edit Telegram message: {str(e)}" + ) + + +@tool() +async def telegram_get_updates( + limit: int = 10, offset: int | None = None +) -> TelegramUpdatesResponse: + """Get recent updates (incoming messages) from the Telegram bot. + + Uses the getUpdates Bot API method. Returns updates in chronological order. + Use the offset parameter to acknowledge processed updates and avoid + receiving them again. + + Args: + limit: Maximum number of updates to retrieve (default 10, max 100). + offset: Identifier of the first update to return. Set to + last_update_id + 1 to acknowledge all previous updates. + + Returns: + Response containing a list of recent updates. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return TelegramUpdatesResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + if limit < 1: + return TelegramUpdatesResponse( + success=False, error="limit must be at least 1" + ) + + capped_limit = min(limit, 100) + params: dict = {"limit": capped_limit} + if offset is not None: + params["offset"] = offset + + logger.info( + "Fetching Telegram updates limit=%d offset=%s", capped_limit, offset + ) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{bot_url}/getUpdates", + params=params, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return TelegramUpdatesResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + raw_updates = data.get("result", []) + updates = [ + TelegramUpdate( + update_id=u["update_id"], + message_text=u.get("message", {}).get("text"), + chat_id=u.get("message", {}).get("chat", {}).get("id"), + from_user=u.get("message", {}).get("from", {}).get("username"), + date=u.get("message", {}).get("date"), + ) + for u in raw_updates + ] + + return TelegramUpdatesResponse( + success=True, + data=TelegramUpdatesData(updates=updates, count=len(updates)), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram get_updates HTTP error") + return TelegramUpdatesResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram get_updates failed") + return TelegramUpdatesResponse( + success=False, error=f"Failed to get Telegram updates: {str(e)}" + ) + + +@tool() +async def telegram_pin_message( + chat_id: str, + message_id: int, + disable_notification: bool = False, +) -> TelegramPinMessageResponse: + """Pin a message in a Telegram chat. + + Uses the pinChatMessage Bot API method. The bot must be an administrator + in the group or channel with the can_pin_messages permission. + + Args: + chat_id: The unique identifier for the chat or username of the + target channel (in the format @channelusername). + message_id: The ID of the message to pin. + disable_notification: If true, the notification is sent silently + and users receive a notification with no sound. + + Returns: + Response indicating success, or an error. + """ + try: + bot_url = _get_bot_url() + if bot_url is None: + return TelegramPinMessageResponse( + success=False, + error="Telegram not configured. Set TELEGRAM_BOT_TOKEN environment variable.", + ) + + payload: dict = { + "chat_id": chat_id, + "message_id": message_id, + } + if disable_notification: + payload["disable_notification"] = True + + logger.info( + "Pinning Telegram message chat_id=%s message_id=%d", chat_id, message_id + ) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{bot_url}/pinChatMessage", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if not data.get("ok"): + return TelegramPinMessageResponse( + success=False, + error=f"Telegram API error: {data.get('description', 'Unknown error')}", + ) + + return TelegramPinMessageResponse( + success=True, + data=TelegramPinnedMessageData( + chat_id=chat_id, + message_id=message_id, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Telegram pin_message HTTP error") + return TelegramPinMessageResponse( + success=False, + error=f"Telegram API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Telegram pin_message failed") + return TelegramPinMessageResponse( + success=False, error=f"Failed to pin Telegram message: {str(e)}" + ) diff --git a/src/tools/messaging/twilio.py b/src/tools/messaging/twilio.py new file mode 100644 index 0000000..34ffedc --- /dev/null +++ b/src/tools/messaging/twilio.py @@ -0,0 +1,258 @@ +"""Twilio tools for sending SMS messages, making voice calls, and checking message status. + +Uses the Twilio REST API via the twilio Python package. +See https://www.twilio.com/docs for full documentation. +Requires TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables. +""" + +from __future__ import annotations + +import logging +import re + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + GetSmsStatusResponse, + MakeVoiceCallResponse, + SendSmsResponse, + SmsSentData, + SmsStatusData, + VoiceCallData, +) + +try: + from twilio.base.exceptions import TwilioRestException + from twilio.rest import Client +except ImportError as err: + raise ImportError( + "twilio is required for Twilio tools. Install with: pip install twilio" + ) from err + +logger = logging.getLogger("humcp.tools.twilio") + +E164_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$") + + +def _validate_phone_number(phone: str) -> bool: + """Validate that a phone number is in E.164 format.""" + return bool(E164_PATTERN.match(phone)) + + +def _get_client(account_sid: str, auth_token: str) -> Client: + """Create a Twilio client from the provided credentials.""" + return Client(account_sid, auth_token) + + +@tool() +async def twilio_send_sms( + to: str, + body: str, + from_number: str | None = None, +) -> SendSmsResponse: + """Send an SMS message using Twilio. + + Uses the POST /Messages endpoint of the Twilio REST API. + + Args: + to: Recipient phone number in E.164 format (e.g., "+1234567890"). + body: The text content of the SMS message (up to 1600 characters). + from_number: Sender phone number (must be a Twilio number) in E.164 format. + Defaults to the TWILIO_FROM_NUMBER environment variable. + + Returns: + Response indicating success with message SID and status, or an error. + """ + try: + account_sid = await resolve_credential("TWILIO_ACCOUNT_SID") + auth_token = await resolve_credential("TWILIO_AUTH_TOKEN") + if not account_sid or not auth_token: + return SendSmsResponse( + success=False, + error="Twilio not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables.", + ) + + client = _get_client(account_sid, auth_token) + sender = from_number or await resolve_credential("TWILIO_FROM_NUMBER") + if not sender: + return SendSmsResponse( + success=False, + error="No sender number provided. Set TWILIO_FROM_NUMBER or pass from_number parameter.", + ) + + if not _validate_phone_number(to): + return SendSmsResponse( + success=False, + error="'to' number must be in E.164 format (e.g., +1234567890).", + ) + if not _validate_phone_number(sender): + return SendSmsResponse( + success=False, + error="'from_number' must be in E.164 format (e.g., +1234567890).", + ) + + if not body or not body.strip(): + return SendSmsResponse( + success=False, + error="Message body cannot be empty.", + ) + + logger.info("Sending SMS via Twilio to=%s from=%s", to, sender) + message = client.messages.create( + to=to, + from_=sender, + body=body, + ) + + logger.info("SMS sent successfully sid=%s to=%s", message.sid, to) + return SendSmsResponse( + success=True, + data=SmsSentData( + message_sid=message.sid, + to=to, + from_number=sender, + status=message.status, + ), + ) + except TwilioRestException as e: + logger.exception("Twilio send_sms failed") + return SendSmsResponse(success=False, error=f"Twilio API error: {str(e)}") + except Exception as e: + logger.exception("Twilio send_sms failed") + return SendSmsResponse(success=False, error=f"Failed to send SMS: {str(e)}") + + +@tool() +async def twilio_get_sms_status(message_sid: str) -> GetSmsStatusResponse: + """Get the delivery status of a Twilio SMS message. + + Uses the GET /Messages/{MessageSid} endpoint. Useful for checking + whether a message has been delivered, failed, or is still in progress. + + Args: + message_sid: The SID of the message to check (starts with "SM"). + + Returns: + Response containing the message status and details. + """ + try: + account_sid = await resolve_credential("TWILIO_ACCOUNT_SID") + auth_token = await resolve_credential("TWILIO_AUTH_TOKEN") + if not account_sid or not auth_token: + return GetSmsStatusResponse( + success=False, + error="Twilio not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables.", + ) + + client = _get_client(account_sid, auth_token) + if not message_sid: + return GetSmsStatusResponse(success=False, error="message_sid is required.") + + logger.info("Fetching SMS status for sid=%s", message_sid) + message = client.messages(message_sid).fetch() + + return GetSmsStatusResponse( + success=True, + data=SmsStatusData( + message_sid=message.sid, + to=message.to, + from_number=message.from_, + status=message.status, + date_sent=str(message.date_sent) if message.date_sent else None, + error_code=message.error_code, + error_message=message.error_message, + ), + ) + except TwilioRestException as e: + logger.exception("Twilio get_sms_status failed") + return GetSmsStatusResponse(success=False, error=f"Twilio API error: {str(e)}") + except Exception as e: + logger.exception("Twilio get_sms_status failed") + return GetSmsStatusResponse( + success=False, error=f"Failed to get SMS status: {str(e)}" + ) + + +@tool() +async def twilio_make_call( + to: str, + twiml: str, + from_number: str | None = None, +) -> MakeVoiceCallResponse: + """Initiate an outbound voice call using Twilio. + + Uses the POST /Calls endpoint of the Twilio REST API. The call behavior + is controlled by TwiML (Twilio Markup Language) provided in the twiml + parameter. + + Args: + to: Recipient phone number in E.164 format (e.g., "+1234567890"). + twiml: TwiML instructions that control the call. For example, + 'Hello!' will speak the text + when the call is answered. See https://www.twilio.com/docs/voice/twiml + for all available TwiML verbs. + from_number: Caller ID phone number (must be a Twilio number) in E.164 format. + Defaults to the TWILIO_FROM_NUMBER environment variable. + + Returns: + Response indicating success with call SID and status, or an error. + """ + try: + account_sid = await resolve_credential("TWILIO_ACCOUNT_SID") + auth_token = await resolve_credential("TWILIO_AUTH_TOKEN") + if not account_sid or not auth_token: + return MakeVoiceCallResponse( + success=False, + error="Twilio not configured. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables.", + ) + + client = _get_client(account_sid, auth_token) + sender = from_number or await resolve_credential("TWILIO_FROM_NUMBER") + if not sender: + return MakeVoiceCallResponse( + success=False, + error="No caller ID provided. Set TWILIO_FROM_NUMBER or pass from_number parameter.", + ) + + if not _validate_phone_number(to): + return MakeVoiceCallResponse( + success=False, + error="'to' number must be in E.164 format (e.g., +1234567890).", + ) + if not _validate_phone_number(sender): + return MakeVoiceCallResponse( + success=False, + error="'from_number' must be in E.164 format (e.g., +1234567890).", + ) + + if not twiml or not twiml.strip(): + return MakeVoiceCallResponse( + success=False, + error="twiml cannot be empty. Provide TwiML instructions for the call.", + ) + + logger.info("Making voice call via Twilio to=%s from=%s", to, sender) + call = client.calls.create( + to=to, + from_=sender, + twiml=twiml, + ) + + logger.info("Voice call initiated sid=%s to=%s", call.sid, to) + return MakeVoiceCallResponse( + success=True, + data=VoiceCallData( + call_sid=call.sid, + to=to, + from_number=sender, + status=call.status, + ), + ) + except TwilioRestException as e: + logger.exception("Twilio make_call failed") + return MakeVoiceCallResponse(success=False, error=f"Twilio API error: {str(e)}") + except Exception as e: + logger.exception("Twilio make_call failed") + return MakeVoiceCallResponse( + success=False, error=f"Failed to make voice call: {str(e)}" + ) diff --git a/src/tools/messaging/webex.py b/src/tools/messaging/webex.py new file mode 100644 index 0000000..7c575d9 --- /dev/null +++ b/src/tools/messaging/webex.py @@ -0,0 +1,297 @@ +"""Webex messaging tools for sending messages, managing rooms, and listing participants. + +Uses the Webex REST API v1 (https://developer.webex.com/docs/api/v1/). +Note: Webex "rooms" are also known as "spaces" in the Webex UI. +Requires the WEBEX_ACCESS_TOKEN environment variable. +""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + CreateRoomResponse, + GetRoomDetailsResponse, + ListRoomsData, + ListRoomsResponse, + MessageSentData, + SendMessageResponse, + WebexRoomCreatedData, + WebexRoomDetailsData, + WebexRoomInfo, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Webex tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.webex") + +WEBEX_API_BASE = "https://webexapis.com/v1" + + +def _get_headers(token: str) -> dict[str, str]: + """Build Webex API headers from the provided token.""" + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +@tool() +async def webex_send_message( + room_id: str, + text: str | None = None, + markdown: str | None = None, +) -> SendMessageResponse: + """Send a message to a Webex room (space). + + Uses the POST /messages endpoint. You can send plain text, Markdown, or both. + If both are provided, Markdown is used for rich rendering and plain text + serves as the fallback. + + Args: + room_id: The ID of the Webex room to send the message to. + text: Plain text message content. Used as a fallback when markdown + is also provided, or as the primary content when markdown is omitted. + markdown: Markdown-formatted message content. Supports bold (**), italic (*), + links [text](url), ordered/unordered lists, headings, code blocks, + and mentions (<@personId>). Max 7439 bytes. + + Returns: + Response indicating success with message details, or an error. + """ + try: + token = await resolve_credential("WEBEX_ACCESS_TOKEN") + if not token: + return SendMessageResponse( + success=False, + error="Webex not configured. Set WEBEX_ACCESS_TOKEN environment variable.", + ) + + headers = _get_headers(token) + if not text and not markdown: + return SendMessageResponse( + success=False, + error="Either text or markdown must be provided.", + ) + + payload: dict = {"roomId": room_id} + if text: + payload["text"] = text + if markdown: + payload["markdown"] = markdown + + logger.info("Sending Webex message to room_id=%s", room_id) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{WEBEX_API_BASE}/messages", + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=data.get("id"), + channel=data.get("roomId"), + timestamp=data.get("created"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Webex send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"Webex API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Webex send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send Webex message: {str(e)}" + ) + + +@tool() +async def webex_create_room( + title: str, team_id: str | None = None +) -> CreateRoomResponse: + """Create a new Webex room (space). + + Uses the POST /rooms endpoint. The authenticated user is automatically + added as a member. If team_id is provided, the room is created within + that team (and cannot be moved later). + + Args: + title: The title of the new room. + team_id: Optional team ID to associate the room with. + + Returns: + Response indicating success with the new room details. + """ + try: + token = await resolve_credential("WEBEX_ACCESS_TOKEN") + if not token: + return CreateRoomResponse( + success=False, + error="Webex not configured. Set WEBEX_ACCESS_TOKEN environment variable.", + ) + + headers = _get_headers(token) + payload: dict = {"title": title} + if team_id: + payload["teamId"] = team_id + + logger.info("Creating Webex room title=%s", title) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{WEBEX_API_BASE}/rooms", + headers=headers, + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return CreateRoomResponse( + success=True, + data=WebexRoomCreatedData( + id=data["id"], + title=data.get("title", title), + type=data.get("type"), + created=data.get("created"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Webex create_room HTTP error") + return CreateRoomResponse( + success=False, + error=f"Webex API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Webex create_room failed") + return CreateRoomResponse( + success=False, error=f"Failed to create Webex room: {str(e)}" + ) + + +@tool() +async def webex_get_room_details(room_id: str) -> GetRoomDetailsResponse: + """Get detailed information about a Webex room (space). + + Uses the GET /rooms/{roomId} endpoint. Returns the room's title, type, + lock status, team association, and creation details. + + Args: + room_id: The ID of the Webex room to get details for. + + Returns: + Response containing the room's detailed information. + """ + try: + token = await resolve_credential("WEBEX_ACCESS_TOKEN") + if not token: + return GetRoomDetailsResponse( + success=False, + error="Webex not configured. Set WEBEX_ACCESS_TOKEN environment variable.", + ) + + headers = _get_headers(token) + logger.info("Fetching Webex room details room_id=%s", room_id) + async with httpx.AsyncClient() as client: + response = await client.get( + f"{WEBEX_API_BASE}/rooms/{room_id}", + headers=headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return GetRoomDetailsResponse( + success=True, + data=WebexRoomDetailsData( + id=data["id"], + title=data.get("title", ""), + type=data.get("type"), + is_locked=data.get("isLocked"), + team_id=data.get("teamId"), + created=data.get("created"), + creator_id=data.get("creatorId"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("Webex get_room_details HTTP error") + return GetRoomDetailsResponse( + success=False, + error=f"Webex API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Webex get_room_details failed") + return GetRoomDetailsResponse( + success=False, error=f"Failed to get Webex room details: {str(e)}" + ) + + +@tool() +async def webex_list_rooms() -> ListRoomsResponse: + """List all rooms (spaces) the Webex bot or user is a member of. + + Uses the GET /rooms endpoint. Returns up to 100 rooms sorted by + last activity. + + Returns: + Response containing a list of rooms with their IDs, titles, and types. + """ + try: + token = await resolve_credential("WEBEX_ACCESS_TOKEN") + if not token: + return ListRoomsResponse( + success=False, + error="Webex not configured. Set WEBEX_ACCESS_TOKEN environment variable.", + ) + + headers = _get_headers(token) + logger.info("Listing Webex rooms") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{WEBEX_API_BASE}/rooms", + headers=headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + raw_rooms = data.get("items", []) + rooms = [ + WebexRoomInfo( + id=room["id"], + title=room.get("title", ""), + type=room.get("type"), + is_locked=room.get("isLocked"), + created=room.get("created"), + ) + for room in raw_rooms + ] + + return ListRoomsResponse( + success=True, + data=ListRoomsData(rooms=rooms, count=len(rooms)), + ) + except httpx.HTTPStatusError as e: + logger.exception("Webex list_rooms HTTP error") + return ListRoomsResponse( + success=False, + error=f"Webex API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("Webex list_rooms failed") + return ListRoomsResponse( + success=False, error=f"Failed to list Webex rooms: {str(e)}" + ) diff --git a/src/tools/messaging/whatsapp.py b/src/tools/messaging/whatsapp.py new file mode 100644 index 0000000..26302fd --- /dev/null +++ b/src/tools/messaging/whatsapp.py @@ -0,0 +1,311 @@ +"""WhatsApp Cloud API tools for sending text, template, and media messages. + +Uses the Meta WhatsApp Business Cloud API (https://developers.facebook.com/docs/whatsapp/cloud-api). +Requires WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables. +""" + +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.messaging.schemas import ( + MessageSentData, + SendMessageResponse, + SendWhatsAppMediaResponse, + SendWhatsAppTemplateResponse, + WhatsAppMediaSentData, + WhatsAppTemplateSentData, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for WhatsApp tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.whatsapp") + +WHATSAPP_API_BASE = "https://graph.facebook.com" +WHATSAPP_API_VERSION = "v22.0" + + +def _get_config() -> tuple[str, str, dict[str, str]] | None: + """Get WhatsApp API configuration from environment. + + Returns: + Tuple of (url, phone_number_id, headers) or None if not configured. + """ + token = os.getenv("WHATSAPP_TOKEN") + phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID") + if not token or not phone_number_id: + return None + + url = f"{WHATSAPP_API_BASE}/{WHATSAPP_API_VERSION}/{phone_number_id}/messages" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + return url, phone_number_id, headers + + +@tool() +async def whatsapp_send_message( + to: str, message: str, preview_url: bool = False +) -> SendMessageResponse: + """Send a text message via the WhatsApp Cloud API. + + Uses the POST /{phone_number_id}/messages endpoint. The recipient must + have an active WhatsApp account on the given phone number. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890" for a US number). Must include country code. + message: The text message to send (up to 4096 characters). + preview_url: If true, WhatsApp will attempt to render a link preview + for the first URL found in the message text. + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendMessageResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + url, _, headers = config + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": "text", + "text": {"preview_url": preview_url, "body": message}, + } + + logger.info("Sending WhatsApp message to=%s", to) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp message sent successfully to=%s", to) + return SendMessageResponse( + success=True, + data=MessageSentData( + message_id=message_id, + channel=to, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_message HTTP error") + return SendMessageResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_message failed") + return SendMessageResponse( + success=False, error=f"Failed to send WhatsApp message: {str(e)}" + ) + + +@tool() +async def whatsapp_send_template( + to: str, + template_name: str, + language_code: str = "en_US", + header_params: str | None = None, + body_params: str | None = None, +) -> SendWhatsAppTemplateResponse: + """Send a pre-approved template message via the WhatsApp Cloud API. + + Uses the POST /{phone_number_id}/messages endpoint with type "template". + Templates must be created and approved in the Meta Business Manager before + they can be used. Template messages are required to initiate conversations + outside the 24-hour customer service window. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890"). Must include country code. + template_name: The name of the approved message template. + language_code: The language/locale code for the template (default "en_US"). + Must match a language the template was approved for. + header_params: Optional comma-separated parameter values for the template + header (e.g., "John Doe" or "Order #123"). + body_params: Optional comma-separated parameter values for the template + body placeholders (e.g., "John,42,shipped"). + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendWhatsAppTemplateResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + url, _, headers = config + + template: dict = { + "name": template_name, + "language": {"code": language_code}, + } + + components: list[dict] = [] + if header_params: + params = [ + {"type": "text", "text": p.strip()} for p in header_params.split(",") + ] + components.append({"type": "header", "parameters": params}) + + if body_params: + params = [ + {"type": "text", "text": p.strip()} for p in body_params.split(",") + ] + components.append({"type": "body", "parameters": params}) + + if components: + template["components"] = components + + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": "template", + "template": template, + } + + logger.info( + "Sending WhatsApp template=%s to=%s lang=%s", + template_name, + to, + language_code, + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp template sent successfully to=%s", to) + return SendWhatsAppTemplateResponse( + success=True, + data=WhatsAppTemplateSentData( + message_id=message_id, + to=to, + template_name=template_name, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_template HTTP error") + return SendWhatsAppTemplateResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_template failed") + return SendWhatsAppTemplateResponse( + success=False, error=f"Failed to send WhatsApp template: {str(e)}" + ) + + +@tool() +async def whatsapp_send_media( + to: str, + media_type: str, + media_url: str, + caption: str | None = None, + filename: str | None = None, +) -> SendWhatsAppMediaResponse: + """Send a media message (image, document, video, or audio) via WhatsApp. + + Uses the POST /{phone_number_id}/messages endpoint with the specified + media type. The media must be accessible via a public URL. + + Args: + to: Recipient's phone number in international format without the leading + '+' sign (e.g., "1234567890"). Must include country code. + media_type: Type of media to send. Must be one of: "image", "document", + "video", "audio". Images support JPEG/PNG (max 5 MB), + documents support PDF/DOC/etc. (max 100 MB), videos support + MP4 (max 16 MB), audio supports MP3/OGG (max 16 MB). + media_url: Public URL of the media file to send. + caption: Optional caption for the media (supported for image, video, + and document types; up to 1024 characters). + filename: Optional filename for documents (shown to the recipient). + + Returns: + Response indicating success with message ID, or an error. + """ + try: + config = _get_config() + if config is None: + return SendWhatsAppMediaResponse( + success=False, + error="WhatsApp not configured. Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID environment variables.", + ) + + valid_types = {"image", "document", "video", "audio"} + if media_type not in valid_types: + return SendWhatsAppMediaResponse( + success=False, + error=f"Invalid media_type '{media_type}'. Must be one of: {', '.join(sorted(valid_types))}.", + ) + + url, _, headers = config + + media_payload: dict = {"link": media_url} + if caption and media_type in {"image", "video", "document"}: + media_payload["caption"] = caption + if filename and media_type == "document": + media_payload["filename"] = filename + + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to, + "type": media_type, + media_type: media_payload, + } + + logger.info("Sending WhatsApp %s to=%s url=%s", media_type, to, media_url) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload, timeout=60) + response.raise_for_status() + data = response.json() + + messages_list = data.get("messages", []) + message_id = messages_list[0].get("id") if messages_list else None + + logger.info("WhatsApp %s sent successfully to=%s", media_type, to) + return SendWhatsAppMediaResponse( + success=True, + data=WhatsAppMediaSentData( + message_id=message_id, + to=to, + media_type=media_type, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("WhatsApp send_media HTTP error") + return SendWhatsAppMediaResponse( + success=False, + error=f"WhatsApp API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("WhatsApp send_media failed") + return SendWhatsAppMediaResponse( + success=False, error=f"Failed to send WhatsApp media: {str(e)}" + ) diff --git a/src/tools/project_management/SKILL.md b/src/tools/project_management/SKILL.md new file mode 100644 index 0000000..2d3b073 --- /dev/null +++ b/src/tools/project_management/SKILL.md @@ -0,0 +1,97 @@ +--- +name: project-management +description: Tools for interacting with project management, issue tracking, wiki, and code hosting services. Use when the user needs to create, read, or search issues, tasks, tickets, pages, or repositories across services like Jira, Linear, ClickUp, Todoist, Trello, Confluence, Notion, Bitbucket, GitHub, or Zendesk. +--- + +# Project Management Tools + +Tools for managing issues, tasks, tickets, wiki pages, repositories, and pull requests across popular project management and developer services. + +## Supported Services + +| Service | Tools | Env Variables | +|---------|-------|---------------| +| Jira | `jira_get_issue`, `jira_create_issue`, `jira_search_issues` | `JIRA_URL`, `JIRA_USERNAME`, `JIRA_API_TOKEN` | +| Linear | `linear_create_issue`, `linear_get_issue`, `linear_list_issues` | `LINEAR_API_KEY` | +| ClickUp | `clickup_create_task`, `clickup_get_task` | `CLICKUP_API_KEY` | +| Todoist | `todoist_create_task`, `todoist_get_tasks` | `TODOIST_API_KEY` | +| Trello | `trello_create_card`, `trello_get_boards` | `TRELLO_API_KEY`, `TRELLO_TOKEN` | +| Confluence | `confluence_get_page`, `confluence_create_page`, `confluence_search` | `CONFLUENCE_URL`, `CONFLUENCE_USERNAME`, `CONFLUENCE_API_TOKEN` | +| Notion | `notion_get_page`, `notion_create_page`, `notion_search` | `NOTION_API_KEY` | +| Bitbucket | `bitbucket_list_repos`, `bitbucket_get_repo`, `bitbucket_create_pr` | `BITBUCKET_USERNAME`, `BITBUCKET_APP_PASSWORD` | +| GitHub | `github_get_repo`, `github_list_issues`, `github_create_issue` | `GITHUB_TOKEN` | +| Zendesk | `zendesk_create_ticket`, `zendesk_get_ticket`, `zendesk_search_tickets` | `ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN` | + +## Usage Examples + +### Jira - Search Issues + +```python +result = await jira_search_issues( + jql="project = PROJ AND status = 'In Progress'", + max_results=10 +) +``` + +### Linear - Create Issue + +```python +result = await linear_create_issue( + title="Fix login bug", + description="Users cannot log in with SSO", + team_id="team-uuid-here" +) +``` + +### GitHub - List Issues + +```python +result = await github_list_issues( + owner="myorg", + repo="myrepo", + state="open" +) +``` + +### Zendesk - Create Ticket + +```python +result = await zendesk_create_ticket( + subject="Cannot access dashboard", + description="User reports 500 error on dashboard page", + priority="high" +) +``` + +## Response Format + +All tools return a consistent response format: + +```json +{ + "success": true, + "data": { + "id": "PROJ-123", + "title": "Issue title", + "status": "In Progress", + "url": "https://..." + } +} +``` + +On error: + +```json +{ + "success": false, + "error": "Descriptive error message" +} +``` + +## When to Use + +- Creating, retrieving, or searching issues and tasks +- Managing wiki pages and documentation +- Listing repositories and creating pull requests +- Creating and searching support tickets +- Cross-service project management workflows diff --git a/src/tools/project_management/__init__.py b/src/tools/project_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/project_management/bitbucket.py b/src/tools/project_management/bitbucket.py new file mode 100644 index 0000000..3f6859c --- /dev/null +++ b/src/tools/project_management/bitbucket.py @@ -0,0 +1,391 @@ +"""Bitbucket tools for repository, pull request, and commit management. + +Uses the Bitbucket Cloud REST API 2.0. Requires BITBUCKET_USERNAME and +BITBUCKET_APP_PASSWORD environment variables. + +API Reference: https://developer.atlassian.com/cloud/bitbucket/rest/ +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + BitbucketCommitData, + BitbucketCommitListData, + BitbucketCommitListResponse, + BitbucketPullRequestData, + BitbucketPullRequestListData, + BitbucketPullRequestListResponse, + BitbucketPullRequestResponse, + BitbucketRepoData, + BitbucketRepoListData, + BitbucketRepoListResponse, + BitbucketRepoResponse, +) + +logger = logging.getLogger("humcp.tools.bitbucket") + +BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0" + + +async def _get_auth() -> tuple[tuple[str, str] | None, str | None]: + """Build Bitbucket basic auth credentials from environment variables. + + Returns: + A tuple of ((username, app_password), error_message). + """ + username = await resolve_credential("BITBUCKET_USERNAME") + app_password = await resolve_credential("BITBUCKET_APP_PASSWORD") + + if not username: + return ( + None, + "Bitbucket username not configured. Set BITBUCKET_USERNAME environment variable.", + ) + if not app_password: + return ( + None, + "Bitbucket app password not configured. Set BITBUCKET_APP_PASSWORD environment variable.", + ) + + return (username, app_password), None + + +@tool() +async def bitbucket_list_repos( + workspace: str, + page_len: int = 25, +) -> BitbucketRepoListResponse: + """List repositories in a Bitbucket workspace. + + Args: + workspace: The Bitbucket workspace slug or UUID. + page_len: Maximum number of repositories to return (max 100). + + Returns: + List of repositories in the workspace. + """ + try: + auth, error = await _get_auth() + if error or auth is None: + return BitbucketRepoListResponse(success=False, error=error) + + if page_len < 1: + return BitbucketRepoListResponse( + success=False, error="page_len must be at least 1" + ) + + params = {"pagelen": min(page_len, 100)} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BITBUCKET_API_BASE}/repositories/{workspace}", + params=params, + auth=auth, + ) + response.raise_for_status() + data = response.json() + + repos = [ + BitbucketRepoData( + slug=repo["slug"], + name=repo.get("name", repo["slug"]), + full_name=repo.get("full_name"), + description=repo.get("description"), + is_private=repo.get("is_private", False), + language=repo.get("language"), + url=repo.get("links", {}).get("html", {}).get("href"), + ) + for repo in data.get("values", []) + ] + + logger.info("Listed %d Bitbucket repos in workspace %s", len(repos), workspace) + + return BitbucketRepoListResponse( + success=True, + data=BitbucketRepoListData( + repos=repos, + total=data.get("size", len(repos)), + ), + ) + except Exception as e: + logger.exception("Failed to list Bitbucket repos for workspace %s", workspace) + return BitbucketRepoListResponse( + success=False, error=f"Failed to list repos: {e}" + ) + + +@tool() +async def bitbucket_get_repo( + workspace: str, + repo_slug: str, +) -> BitbucketRepoResponse: + """Get details of a specific Bitbucket repository. + + Args: + workspace: The Bitbucket workspace slug or UUID. + repo_slug: The repository slug. + + Returns: + Repository details including name, description, language, and visibility. + """ + try: + auth, error = await _get_auth() + if error or auth is None: + return BitbucketRepoResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BITBUCKET_API_BASE}/repositories/{workspace}/{repo_slug}", + auth=auth, + ) + response.raise_for_status() + repo = response.json() + + data = BitbucketRepoData( + slug=repo["slug"], + name=repo.get("name", repo["slug"]), + full_name=repo.get("full_name"), + description=repo.get("description"), + is_private=repo.get("is_private", False), + language=repo.get("language"), + url=repo.get("links", {}).get("html", {}).get("href"), + ) + + return BitbucketRepoResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Bitbucket repo %s/%s", workspace, repo_slug) + return BitbucketRepoResponse( + success=False, error=f"Failed to get repository: {e}" + ) + + +@tool() +async def bitbucket_create_pr( + workspace: str, + repo_slug: str, + title: str, + source_branch: str, + dest_branch: str = "main", + description: str = "", + close_source_branch: bool = False, +) -> BitbucketPullRequestResponse: + """Create a pull request in a Bitbucket repository. + + Args: + workspace: The Bitbucket workspace slug or UUID. + repo_slug: The repository slug. + title: The title of the pull request. + source_branch: The source branch name. + dest_branch: The destination branch name (defaults to "main"). + description: Optional description for the pull request. + close_source_branch: Whether to close the source branch after merge. + + Returns: + Details of the newly created pull request. + """ + try: + auth, error = await _get_auth() + if error or auth is None: + return BitbucketPullRequestResponse(success=False, error=error) + + payload: dict = { + "title": title, + "description": description, + "source": {"branch": {"name": source_branch}}, + "destination": {"branch": {"name": dest_branch}}, + "close_source_branch": close_source_branch, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{BITBUCKET_API_BASE}/repositories/{workspace}/{repo_slug}/pullrequests", + json=payload, + auth=auth, + ) + response.raise_for_status() + pr = response.json() + + data = BitbucketPullRequestData( + id=pr["id"], + title=pr["title"], + description=pr.get("description"), + state=pr.get("state"), + author=pr.get("author", {}).get("display_name") + if pr.get("author") + else None, + source_branch=pr.get("source", {}).get("branch", {}).get("name"), + dest_branch=pr.get("destination", {}).get("branch", {}).get("name"), + url=pr.get("links", {}).get("html", {}).get("href"), + created_on=pr.get("created_on"), + ) + + logger.info("Created Bitbucket PR #%d in %s/%s", pr["id"], workspace, repo_slug) + + return BitbucketPullRequestResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Bitbucket PR in %s/%s", workspace, repo_slug) + return BitbucketPullRequestResponse( + success=False, error=f"Failed to create pull request: {e}" + ) + + +@tool() +async def bitbucket_list_pull_requests( + workspace: str, + repo_slug: str, + state: str = "OPEN", + page_len: int = 25, +) -> BitbucketPullRequestListResponse: + """List pull requests in a Bitbucket repository. + + Args: + workspace: The Bitbucket workspace slug or UUID. + repo_slug: The repository slug. + state: Filter by PR state: "OPEN", "MERGED", "DECLINED", or "SUPERSEDED". + Can be comma-separated for multiple states (e.g., "OPEN,MERGED"). + page_len: Maximum number of pull requests to return (max 50). + + Returns: + List of pull requests matching the filter. + """ + try: + auth, error = await _get_auth() + if error or auth is None: + return BitbucketPullRequestListResponse(success=False, error=error) + + if page_len < 1: + return BitbucketPullRequestListResponse( + success=False, error="page_len must be at least 1" + ) + + params: dict = { + "state": state, + "pagelen": min(page_len, 50), + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{BITBUCKET_API_BASE}/repositories/{workspace}/{repo_slug}/pullrequests", + params=params, + auth=auth, + ) + response.raise_for_status() + result = response.json() + + pull_requests = [ + BitbucketPullRequestData( + id=pr["id"], + title=pr["title"], + description=pr.get("description"), + state=pr.get("state"), + author=pr.get("author", {}).get("display_name") + if pr.get("author") + else None, + source_branch=pr.get("source", {}).get("branch", {}).get("name"), + dest_branch=pr.get("destination", {}).get("branch", {}).get("name"), + url=pr.get("links", {}).get("html", {}).get("href"), + created_on=pr.get("created_on"), + ) + for pr in result.get("values", []) + ] + + logger.info( + "Listed %d Bitbucket PRs in %s/%s (state=%s)", + len(pull_requests), + workspace, + repo_slug, + state, + ) + + return BitbucketPullRequestListResponse( + success=True, + data=BitbucketPullRequestListData( + pull_requests=pull_requests, + total=result.get("size", len(pull_requests)), + ), + ) + except Exception as e: + logger.exception("Failed to list Bitbucket PRs in %s/%s", workspace, repo_slug) + return BitbucketPullRequestListResponse( + success=False, error=f"Failed to list pull requests: {e}" + ) + + +@tool() +async def bitbucket_list_commits( + workspace: str, + repo_slug: str, + branch: str | None = None, + page_len: int = 25, +) -> BitbucketCommitListResponse: + """List commits in a Bitbucket repository. + + Args: + workspace: The Bitbucket workspace slug or UUID. + repo_slug: The repository slug. + branch: Optional branch name or commit hash to list commits from. + page_len: Maximum number of commits to return (max 100). + + Returns: + List of commits in the repository. + """ + try: + auth, error = await _get_auth() + if error or auth is None: + return BitbucketCommitListResponse(success=False, error=error) + + if page_len < 1: + return BitbucketCommitListResponse( + success=False, error="page_len must be at least 1" + ) + + url = f"{BITBUCKET_API_BASE}/repositories/{workspace}/{repo_slug}/commits" + if branch: + url = f"{url}/{branch}" + + params = {"pagelen": min(page_len, 100)} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params, auth=auth) + response.raise_for_status() + result = response.json() + + commits = [ + BitbucketCommitData( + hash=commit["hash"], + message=commit.get("message", ""), + author=( + commit.get("author", {}).get("user", {}).get("display_name") + if commit.get("author", {}).get("user") + else commit.get("author", {}).get("raw", "").split("<")[0].strip() + ), + date=commit.get("date"), + url=commit.get("links", {}).get("html", {}).get("href"), + ) + for commit in result.get("values", []) + ] + + logger.info( + "Listed %d Bitbucket commits in %s/%s", + len(commits), + workspace, + repo_slug, + ) + + return BitbucketCommitListResponse( + success=True, + data=BitbucketCommitListData(commits=commits, total=len(commits)), + ) + except Exception as e: + logger.exception( + "Failed to list Bitbucket commits in %s/%s", workspace, repo_slug + ) + return BitbucketCommitListResponse( + success=False, error=f"Failed to list commits: {e}" + ) diff --git a/src/tools/project_management/clickup.py b/src/tools/project_management/clickup.py new file mode 100644 index 0000000..59c045a --- /dev/null +++ b/src/tools/project_management/clickup.py @@ -0,0 +1,329 @@ +"""ClickUp project management tools for task and workspace management. + +Uses the ClickUp API v2. Requires a CLICKUP_API_KEY environment variable +(personal API token or OAuth2 token). + +API Reference: https://developer.clickup.com/reference +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + ClickUpSpaceData, + ClickUpSpaceListData, + ClickUpSpaceListResponse, + ClickUpTaskData, + ClickUpTaskListData, + ClickUpTaskListResponse, + ClickUpTaskResponse, +) + +logger = logging.getLogger("humcp.tools.clickup") + +CLICKUP_BASE_URL = "https://api.clickup.com/api/v2" + + +async def _get_headers() -> tuple[dict[str, str] | None, str | None]: + """Build ClickUp API headers from environment variables. + + Returns: + A tuple of (headers_dict, error_message). + """ + api_key = await resolve_credential("CLICKUP_API_KEY") + if not api_key: + return ( + None, + "ClickUp API key not configured. Set CLICKUP_API_KEY environment variable.", + ) + return {"Authorization": api_key, "Content-Type": "application/json"}, None + + +def _parse_task(task: dict) -> ClickUpTaskData: + """Parse a ClickUp task API response into a ClickUpTaskData model. + + Args: + task: Raw task dict from the ClickUp API. + + Returns: + Parsed ClickUpTaskData. + """ + assignees = [a.get("username", a.get("id", "")) for a in task.get("assignees", [])] + + priority_obj = task.get("priority") + priority_str = ( + priority_obj.get("priority") if isinstance(priority_obj, dict) else None + ) + + return ClickUpTaskData( + id=task["id"], + name=task["name"], + description=task.get("description"), + status=task.get("status", {}).get("status") if task.get("status") else None, + priority=priority_str, + assignees=assignees, + due_date=task.get("due_date"), + url=task.get("url"), + ) + + +@tool() +async def clickup_create_task( + list_id: str, + name: str, + description: str = "", + priority: int | None = None, + due_date: int | None = None, + assignees: list[int] | None = None, +) -> ClickUpTaskResponse: + """Create a new task in a ClickUp list. + + Args: + list_id: The ID of the ClickUp list to add the task to. + name: The name of the task. + description: The description of the task (Markdown supported). + priority: Priority level (1=urgent, 2=high, 3=normal, 4=low). None for no priority. + due_date: Due date as Unix timestamp in milliseconds. + assignees: List of user IDs to assign to the task. + + Returns: + Details of the newly created task. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return ClickUpTaskResponse(success=False, error=error) + + payload: dict = {"name": name, "description": description} + if priority is not None: + payload["priority"] = priority + if due_date is not None: + payload["due_date"] = due_date + if assignees: + payload["assignees"] = assignees + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{CLICKUP_BASE_URL}/list/{list_id}/task", + json=payload, + headers=headers, + ) + response.raise_for_status() + task = response.json() + + data = _parse_task(task) + + logger.info("Created ClickUp task %s in list %s", task["id"], list_id) + return ClickUpTaskResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create ClickUp task in list %s", list_id) + return ClickUpTaskResponse(success=False, error=f"Failed to create task: {e}") + + +@tool() +async def clickup_get_task(task_id: str) -> ClickUpTaskResponse: + """Retrieve details of a specific ClickUp task by its ID. + + Args: + task_id: The ID of the ClickUp task. + + Returns: + Task details including name, description, status, priority, and assignees. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return ClickUpTaskResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{CLICKUP_BASE_URL}/task/{task_id}", + headers=headers, + ) + response.raise_for_status() + task = response.json() + + data = _parse_task(task) + + return ClickUpTaskResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get ClickUp task %s", task_id) + return ClickUpTaskResponse(success=False, error=f"Failed to get task: {e}") + + +@tool() +async def clickup_update_task( + task_id: str, + name: str | None = None, + description: str | None = None, + status: str | None = None, + priority: int | None = None, + due_date: int | None = None, +) -> ClickUpTaskResponse: + """Update an existing ClickUp task. + + Args: + task_id: The ID of the task to update. + name: New task name. + description: New task description. + status: New status name (must match a status in the task's list). + priority: New priority (1=urgent, 2=high, 3=normal, 4=low). Use 0 to clear. + due_date: New due date as Unix timestamp in milliseconds. + + Returns: + Updated task details. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return ClickUpTaskResponse(success=False, error=error) + + payload: dict = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if status is not None: + payload["status"] = status + if priority is not None: + payload["priority"] = priority + if due_date is not None: + payload["due_date"] = due_date + + if not payload: + return ClickUpTaskResponse( + success=False, error="At least one field must be provided to update." + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{CLICKUP_BASE_URL}/task/{task_id}", + json=payload, + headers=headers, + ) + response.raise_for_status() + task = response.json() + + data = _parse_task(task) + + logger.info("Updated ClickUp task %s", task_id) + return ClickUpTaskResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update ClickUp task %s", task_id) + return ClickUpTaskResponse(success=False, error=f"Failed to update task: {e}") + + +@tool() +async def clickup_list_tasks( + list_id: str, + page: int = 0, + statuses: list[str] | None = None, + assignees: list[str] | None = None, +) -> ClickUpTaskListResponse: + """List tasks in a ClickUp list. Returns up to 100 tasks per page. + + Args: + list_id: The ID of the ClickUp list. + page: Page number for pagination (0-indexed). + statuses: Filter by status names (e.g., ["open", "in progress"]). + assignees: Filter by assignee user IDs. + + Returns: + List of tasks in the list. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return ClickUpTaskListResponse(success=False, error=error) + + params: dict = {"page": page} + if statuses: + for i, s in enumerate(statuses): + params["statuses[]"] = ( + s if i == 0 else params.get("statuses[]", "") + f"&statuses[]={s}" + ) + if assignees: + for _a in assignees: + params.setdefault("assignees[]", []) + + # ClickUp uses array query params - build URL manually + query_parts = [f"page={page}"] + if statuses: + for s in statuses: + query_parts.append(f"statuses[]={s}") + if assignees: + for a in assignees: + query_parts.append(f"assignees[]={a}") + + url = f"{CLICKUP_BASE_URL}/list/{list_id}/task?{'&'.join(query_parts)}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + result = response.json() + + tasks = [_parse_task(t) for t in result.get("tasks", [])] + + logger.info("Listed %d ClickUp tasks in list %s", len(tasks), list_id) + + return ClickUpTaskListResponse( + success=True, + data=ClickUpTaskListData(tasks=tasks, total=len(tasks)), + ) + except Exception as e: + logger.exception("Failed to list ClickUp tasks in list %s", list_id) + return ClickUpTaskListResponse( + success=False, error=f"Failed to list tasks: {e}" + ) + + +@tool() +async def clickup_list_spaces( + team_id: str, +) -> ClickUpSpaceListResponse: + """List spaces in a ClickUp workspace (team). + + Args: + team_id: The ID of the ClickUp workspace/team. + + Returns: + List of spaces in the workspace. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return ClickUpSpaceListResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{CLICKUP_BASE_URL}/team/{team_id}/space", + headers=headers, + ) + response.raise_for_status() + result = response.json() + + spaces = [ + ClickUpSpaceData( + id=space["id"], + name=space["name"], + private=space.get("private", False), + ) + for space in result.get("spaces", []) + ] + + logger.info("Listed %d ClickUp spaces in team %s", len(spaces), team_id) + + return ClickUpSpaceListResponse( + success=True, + data=ClickUpSpaceListData(spaces=spaces, total=len(spaces)), + ) + except Exception as e: + logger.exception("Failed to list ClickUp spaces in team %s", team_id) + return ClickUpSpaceListResponse( + success=False, error=f"Failed to list spaces: {e}" + ) diff --git a/src/tools/project_management/confluence.py b/src/tools/project_management/confluence.py new file mode 100644 index 0000000..fc589b0 --- /dev/null +++ b/src/tools/project_management/confluence.py @@ -0,0 +1,389 @@ +"""Confluence wiki tools for page management, search, and space listing. + +Uses the Atlassian Python API library. Requires CONFLUENCE_URL, +CONFLUENCE_USERNAME, and CONFLUENCE_API_TOKEN environment variables. + +API Reference: https://developer.atlassian.com/cloud/confluence/rest/v2/ +""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + ConfluenceCommentData, + ConfluenceCommentListData, + ConfluenceCommentListResponse, + ConfluencePageData, + ConfluencePageListData, + ConfluencePageListResponse, + ConfluencePageResponse, + ConfluenceSpaceData, + ConfluenceSpaceListData, + ConfluenceSpaceListResponse, +) + +try: + from atlassian import Confluence +except ImportError as err: + raise ImportError( + "atlassian-python-api is required for Confluence tools. " + "Install with: pip install atlassian-python-api" + ) from err + +logger = logging.getLogger("humcp.tools.confluence") + + +async def _get_confluence_client() -> tuple[Confluence | None, str | None]: + """Create a Confluence client from environment variables. + + Returns: + A tuple of (client, error_message). + """ + url = await resolve_credential("CONFLUENCE_URL") + username = await resolve_credential("CONFLUENCE_USERNAME") + api_token = await resolve_credential("CONFLUENCE_API_TOKEN") + + if not url: + return ( + None, + "Confluence URL not configured. Set CONFLUENCE_URL environment variable.", + ) + if not username: + return ( + None, + "Confluence username not configured. Set CONFLUENCE_USERNAME environment variable.", + ) + if not api_token: + return ( + None, + "Confluence API token not configured. Set CONFLUENCE_API_TOKEN environment variable.", + ) + + client = Confluence(url=url, username=username, password=api_token) + return client, None + + +@tool() +async def confluence_get_page(page_id: str) -> ConfluencePageResponse: + """Retrieve a Confluence page by its ID. + + Args: + page_id: The ID of the Confluence page to retrieve. + + Returns: + Page details including title, space key, body content (storage format HTML), version, and URL. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluencePageResponse(success=False, error=error) + + page = client.get_page_by_id(page_id, expand="body.storage,space,version") + + if not page: + return ConfluencePageResponse( + success=False, + error=f"Page with ID {page_id} not found.", + ) + + body_content = None + if page.get("body", {}).get("storage", {}).get("value"): + body_content = page["body"]["storage"]["value"] + + space_key = page.get("space", {}).get("key") + version_num = ( + page.get("version", {}).get("number") if page.get("version") else None + ) + base_url = await resolve_credential("CONFLUENCE_URL") or "" + web_link = page.get("_links", {}).get("webui") + page_url = ( + f"{base_url}{web_link}" + if web_link and not web_link.startswith("http") + else web_link + ) + + data = ConfluencePageData( + id=page["id"], + title=page["title"], + space_key=space_key, + body=body_content, + version=version_num, + url=page_url, + ) + + return ConfluencePageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Confluence page %s", page_id) + return ConfluencePageResponse(success=False, error=f"Failed to get page: {e}") + + +@tool() +async def confluence_create_page( + space_key: str, + title: str, + body: str, + parent_id: str | None = None, +) -> ConfluencePageResponse: + """Create a new page in a Confluence space. + + Args: + space_key: The key of the Confluence space (e.g., "DEV"). + title: The title of the new page. + body: The HTML body content of the page (Confluence storage format). + parent_id: Optional parent page ID for creating a child page. + + Returns: + Details of the newly created page. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluencePageResponse(success=False, error=error) + + page = client.create_page(space_key, title, body, parent_id=parent_id) + + logger.info("Created Confluence page '%s' (ID: %s)", title, page["id"]) + + base_url = await resolve_credential("CONFLUENCE_URL") or "" + web_link = page.get("_links", {}).get("webui") + page_url = ( + f"{base_url}{web_link}" + if web_link and not web_link.startswith("http") + else web_link + ) + + data = ConfluencePageData( + id=page["id"], + title=title, + space_key=space_key, + version=1, + url=page_url, + ) + + return ConfluencePageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Confluence page '%s'", title) + return ConfluencePageResponse( + success=False, error=f"Failed to create page: {e}" + ) + + +@tool() +async def confluence_update_page( + page_id: str, + title: str, + body: str, +) -> ConfluencePageResponse: + """Update an existing Confluence page. Automatically increments the version number. + + Args: + page_id: The ID of the page to update. + title: The new page title. + body: The new HTML body content (Confluence storage format). + + Returns: + Details of the updated page. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluencePageResponse(success=False, error=error) + + page = client.update_page(page_id, title, body) + + logger.info("Updated Confluence page '%s' (ID: %s)", title, page_id) + + base_url = await resolve_credential("CONFLUENCE_URL") or "" + web_link = page.get("_links", {}).get("webui") + page_url = ( + f"{base_url}{web_link}" + if web_link and not web_link.startswith("http") + else web_link + ) + version_num = ( + page.get("version", {}).get("number") if page.get("version") else None + ) + + data = ConfluencePageData( + id=str(page.get("id", page_id)), + title=title, + space_key=page.get("space", {}).get("key") if page.get("space") else None, + version=version_num, + url=page_url, + ) + + return ConfluencePageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Confluence page %s", page_id) + return ConfluencePageResponse( + success=False, error=f"Failed to update page: {e}" + ) + + +@tool() +async def confluence_search( + query: str, + space_key: str | None = None, + limit: int = 25, +) -> ConfluencePageListResponse: + """Search for pages in Confluence using CQL (Confluence Query Language). + + Args: + query: The search query string (text search or CQL expression). + space_key: Optional space key to restrict search to a single space. + limit: Maximum number of results to return (max 100). + + Returns: + List of pages matching the search query. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluencePageListResponse(success=False, error=error) + + if limit < 1: + return ConfluencePageListResponse( + success=False, error="limit must be at least 1" + ) + + cql = f'text ~ "{query}"' + if space_key: + cql = f'{cql} AND space = "{space_key}"' + + results = client.cql(cql, limit=min(limit, 100)) + + pages = [] + for result in results.get("results", []): + content = result.get("content", result) + pages.append( + ConfluencePageData( + id=str(content.get("id", "")), + title=content.get("title", ""), + space_key=content.get("space", {}).get("key") + if isinstance(content.get("space"), dict) + else None, + url=content.get("_links", {}).get("webui"), + ) + ) + + logger.info("Confluence search returned %d results for: %s", len(pages), query) + + return ConfluencePageListResponse( + success=True, + data=ConfluencePageListData( + pages=pages, + total=results.get("totalSize", len(pages)), + ), + ) + except Exception as e: + logger.exception("Failed to search Confluence for: %s", query) + return ConfluencePageListResponse(success=False, error=f"Failed to search: {e}") + + +@tool() +async def confluence_list_spaces( + limit: int = 25, +) -> ConfluenceSpaceListResponse: + """List all Confluence spaces accessible to the authenticated user. + + Args: + limit: Maximum number of spaces to return (max 100). + + Returns: + List of Confluence spaces with their keys and names. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluenceSpaceListResponse(success=False, error=error) + + if limit < 1: + return ConfluenceSpaceListResponse( + success=False, error="limit must be at least 1" + ) + + result = client.get_all_spaces(limit=min(limit, 100)) + + spaces_list = result.get("results", []) if isinstance(result, dict) else result + + spaces = [ + ConfluenceSpaceData( + key=space.get("key", ""), + name=space.get("name", ""), + space_type=space.get("type"), + url=space.get("_links", {}).get("webui"), + ) + for space in spaces_list + ] + + logger.info("Listed %d Confluence spaces", len(spaces)) + + return ConfluenceSpaceListResponse( + success=True, + data=ConfluenceSpaceListData(spaces=spaces, total=len(spaces)), + ) + except Exception as e: + logger.exception("Failed to list Confluence spaces") + return ConfluenceSpaceListResponse( + success=False, error=f"Failed to list spaces: {e}" + ) + + +@tool() +async def confluence_get_page_comments( + page_id: str, + limit: int = 25, +) -> ConfluenceCommentListResponse: + """Get comments on a Confluence page. + + Args: + page_id: The ID of the Confluence page. + limit: Maximum number of comments to return. + + Returns: + List of comments on the page. + """ + try: + client, error = await _get_confluence_client() + if error or client is None: + return ConfluenceCommentListResponse(success=False, error=error) + + if limit < 1: + return ConfluenceCommentListResponse( + success=False, error="limit must be at least 1" + ) + + result = client.get_page_comments(page_id, expand="body.storage", depth="all") + + comment_list = result.get("results", []) if isinstance(result, dict) else result + + comments = [] + for comment in comment_list[:limit]: + body_text = comment.get("body", {}).get("storage", {}).get("value", "") + author_name = None + if comment.get("author"): + author_name = comment["author"].get("displayName") + + comments.append( + ConfluenceCommentData( + id=str(comment.get("id", "")), + body=body_text, + author=author_name, + created=comment.get("created"), + ) + ) + + logger.info("Listed %d comments for Confluence page %s", len(comments), page_id) + + return ConfluenceCommentListResponse( + success=True, + data=ConfluenceCommentListData(comments=comments, total=len(comments)), + ) + except Exception as e: + logger.exception("Failed to get comments for Confluence page %s", page_id) + return ConfluenceCommentListResponse( + success=False, error=f"Failed to get comments: {e}" + ) diff --git a/src/tools/project_management/github.py b/src/tools/project_management/github.py new file mode 100644 index 0000000..3a9d91d --- /dev/null +++ b/src/tools/project_management/github.py @@ -0,0 +1,637 @@ +"""GitHub tools for repository, issue, pull request, commit, and release management. + +Uses the GitHub REST API v3. Requires a GITHUB_TOKEN environment variable +with appropriate scopes (repo, read:org). + +API Reference: https://docs.github.com/en/rest +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + GitHubCommitData, + GitHubCommitListData, + GitHubCommitListResponse, + GitHubIssueData, + GitHubIssueListData, + GitHubIssueListResponse, + GitHubIssueResponse, + GitHubPullRequestData, + GitHubPullRequestListData, + GitHubPullRequestListResponse, + GitHubPullRequestResponse, + GitHubReleaseData, + GitHubReleaseListData, + GitHubReleaseListResponse, + GitHubRepoData, + GitHubRepoResponse, +) + +logger = logging.getLogger("humcp.tools.github") + +GITHUB_API_BASE = "https://api.github.com" + + +async def _get_headers() -> tuple[dict[str, str] | None, str | None]: + """Build GitHub API headers from environment variables. + + Returns: + A tuple of (headers_dict, error_message). + """ + token = await resolve_credential("GITHUB_TOKEN") + if not token: + return ( + None, + "GitHub token not configured. Set GITHUB_TOKEN environment variable.", + ) + + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, None + + +@tool() +async def github_get_repo( + owner: str, + repo: str, +) -> GitHubRepoResponse: + """Get details of a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + + Returns: + Repository details including name, description, stars, forks, and language. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubRepoResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}", + headers=headers, + ) + response.raise_for_status() + repo_data = response.json() + + data = GitHubRepoData( + name=repo_data["name"], + full_name=repo_data["full_name"], + description=repo_data.get("description"), + url=repo_data.get("html_url"), + stars=repo_data.get("stargazers_count"), + forks=repo_data.get("forks_count"), + language=repo_data.get("language"), + is_private=repo_data.get("private", False), + default_branch=repo_data.get("default_branch"), + open_issues_count=repo_data.get("open_issues_count"), + ) + + return GitHubRepoResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get GitHub repo %s/%s", owner, repo) + return GitHubRepoResponse(success=False, error=f"Failed to get repository: {e}") + + +@tool() +async def github_list_issues( + owner: str, + repo: str, + state: str = "open", + labels: str | None = None, + assignee: str | None = None, + sort: str = "created", + direction: str = "desc", + per_page: int = 30, +) -> GitHubIssueListResponse: + """List issues in a GitHub repository. Pull requests are excluded. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + state: Filter by issue state: "open", "closed", or "all". + labels: Comma-separated list of label names to filter by (e.g., "bug,enhancement"). + assignee: Filter by assignee login. Use "none" for unassigned or "*" for any. + sort: Sort field: "created", "updated", or "comments". + direction: Sort direction: "asc" or "desc". + per_page: Number of issues per page (max 100). + + Returns: + List of issues in the repository. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubIssueListResponse(success=False, error=error) + + if state not in ("open", "closed", "all"): + return GitHubIssueListResponse( + success=False, + error="state must be one of: open, closed, all", + ) + + if sort not in ("created", "updated", "comments"): + return GitHubIssueListResponse( + success=False, + error="sort must be one of: created, updated, comments", + ) + + if per_page < 1: + return GitHubIssueListResponse( + success=False, error="per_page must be at least 1" + ) + + params: dict = { + "state": state, + "sort": sort, + "direction": direction, + "per_page": min(per_page, 100), + } + if labels: + params["labels"] = labels + if assignee: + params["assignee"] = assignee + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues", + headers=headers, + params=params, + ) + response.raise_for_status() + issues_json = response.json() + + # Filter out pull requests (GitHub API returns PRs in issues endpoint) + issues = [ + GitHubIssueData( + number=issue["number"], + title=issue["title"], + body=issue.get("body"), + state=issue.get("state"), + assignee=( + issue["assignee"]["login"] if issue.get("assignee") else None + ), + url=issue.get("html_url"), + labels=[label["name"] for label in issue.get("labels", [])], + created_at=issue.get("created_at"), + updated_at=issue.get("updated_at"), + ) + for issue in issues_json + if "pull_request" not in issue + ] + + logger.info( + "Listed %d GitHub issues for %s/%s (state=%s)", + len(issues), + owner, + repo, + state, + ) + + return GitHubIssueListResponse( + success=True, + data=GitHubIssueListData(issues=issues, total=len(issues)), + ) + except Exception as e: + logger.exception("Failed to list GitHub issues for %s/%s", owner, repo) + return GitHubIssueListResponse( + success=False, error=f"Failed to list issues: {e}" + ) + + +@tool() +async def github_create_issue( + owner: str, + repo: str, + title: str, + body: str = "", + labels: list[str] | None = None, + assignees: list[str] | None = None, + milestone: int | None = None, +) -> GitHubIssueResponse: + """Create a new issue in a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + title: The issue title. + body: The issue body/description (Markdown supported). + labels: Optional list of label names to apply. + assignees: Optional list of GitHub usernames to assign. + milestone: Optional milestone number to associate with. + + Returns: + Details of the newly created issue. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubIssueResponse(success=False, error=error) + + payload: dict = {"title": title, "body": body} + if labels: + payload["labels"] = labels + if assignees: + payload["assignees"] = assignees + if milestone is not None: + payload["milestone"] = milestone + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues", + json=payload, + headers=headers, + ) + response.raise_for_status() + issue = response.json() + + logger.info("Created GitHub issue #%d in %s/%s", issue["number"], owner, repo) + + data = GitHubIssueData( + number=issue["number"], + title=issue["title"], + body=issue.get("body"), + state=issue.get("state"), + assignee=(issue["assignee"]["login"] if issue.get("assignee") else None), + url=issue.get("html_url"), + labels=[label["name"] for label in issue.get("labels", [])], + created_at=issue.get("created_at"), + updated_at=issue.get("updated_at"), + ) + + return GitHubIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create GitHub issue in %s/%s", owner, repo) + return GitHubIssueResponse(success=False, error=f"Failed to create issue: {e}") + + +@tool() +async def github_list_pull_requests( + owner: str, + repo: str, + state: str = "open", + sort: str = "created", + direction: str = "desc", + per_page: int = 30, +) -> GitHubPullRequestListResponse: + """List pull requests in a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + state: Filter by PR state: "open", "closed", or "all". + sort: Sort field: "created", "updated", or "popularity". + direction: Sort direction: "asc" or "desc". + per_page: Number of pull requests per page (max 100). + + Returns: + List of pull requests in the repository. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubPullRequestListResponse(success=False, error=error) + + if state not in ("open", "closed", "all"): + return GitHubPullRequestListResponse( + success=False, + error="state must be one of: open, closed, all", + ) + + if per_page < 1: + return GitHubPullRequestListResponse( + success=False, error="per_page must be at least 1" + ) + + params = { + "state": state, + "sort": sort, + "direction": direction, + "per_page": min(per_page, 100), + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls", + headers=headers, + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + prs_json = response.json() + + pull_requests = [ + GitHubPullRequestData( + number=pr["number"], + title=pr["title"], + body=pr.get("body"), + state=pr.get("state"), + merged=pr.get("merged_at") is not None, + head_branch=pr.get("head", {}).get("ref"), + base_branch=pr.get("base", {}).get("ref"), + user=pr.get("user", {}).get("login") if pr.get("user") else None, + url=pr.get("html_url"), + created_at=pr.get("created_at"), + ) + for pr in prs_json + ] + + logger.info( + "Listed %d GitHub PRs for %s/%s (state=%s)", + len(pull_requests), + owner, + repo, + state, + ) + + return GitHubPullRequestListResponse( + success=True, + data=GitHubPullRequestListData( + pull_requests=pull_requests, total=len(pull_requests) + ), + ) + except Exception as e: + logger.exception("Failed to list GitHub PRs for %s/%s", owner, repo) + return GitHubPullRequestListResponse( + success=False, error=f"Failed to list pull requests: {e}" + ) + + +@tool() +async def github_get_pull_request( + owner: str, + repo: str, + pull_number: int, +) -> GitHubPullRequestResponse: + """Get details of a specific pull request in a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + pull_number: The pull request number. + + Returns: + Detailed information about the pull request including title, body, state, + branches, and merge status. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubPullRequestResponse(success=False, error=error) + + if pull_number < 1: + return GitHubPullRequestResponse( + success=False, error="pull_number must be at least 1" + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pull_number}", + headers=headers, + ) + response.raise_for_status() + pr = response.json() + + data = GitHubPullRequestData( + number=pr["number"], + title=pr["title"], + body=pr.get("body"), + state=pr.get("state"), + merged=pr.get("merged", False), + head_branch=pr.get("head", {}).get("ref"), + base_branch=pr.get("base", {}).get("ref"), + user=pr.get("user", {}).get("login") if pr.get("user") else None, + url=pr.get("html_url"), + created_at=pr.get("created_at"), + ) + + logger.info("Retrieved GitHub PR #%d in %s/%s", pull_number, owner, repo) + + return GitHubPullRequestResponse(success=True, data=data) + except Exception as e: + logger.exception( + "Failed to get GitHub PR #%d in %s/%s", pull_number, owner, repo + ) + return GitHubPullRequestResponse( + success=False, error=f"Failed to get pull request: {e}" + ) + + +@tool() +async def github_create_pull_request( + owner: str, + repo: str, + title: str, + head: str, + base: str, + body: str = "", + draft: bool = False, +) -> GitHubPullRequestResponse: + """Create a new pull request in a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + title: The pull request title. + head: The branch containing changes (e.g., "feature-branch" or "user:feature-branch" for cross-repo). + base: The branch to merge into (e.g., "main"). + body: The pull request description (Markdown supported). + draft: Whether to create the PR as a draft. + + Returns: + Details of the newly created pull request. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubPullRequestResponse(success=False, error=error) + + payload: dict = { + "title": title, + "head": head, + "base": base, + "body": body, + "draft": draft, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls", + json=payload, + headers=headers, + ) + response.raise_for_status() + pr = response.json() + + logger.info("Created GitHub PR #%d in %s/%s", pr["number"], owner, repo) + + data = GitHubPullRequestData( + number=pr["number"], + title=pr["title"], + body=pr.get("body"), + state=pr.get("state"), + merged=pr.get("merged", False), + head_branch=pr.get("head", {}).get("ref"), + base_branch=pr.get("base", {}).get("ref"), + user=pr.get("user", {}).get("login") if pr.get("user") else None, + url=pr.get("html_url"), + created_at=pr.get("created_at"), + ) + + return GitHubPullRequestResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create GitHub PR in %s/%s", owner, repo) + return GitHubPullRequestResponse( + success=False, error=f"Failed to create pull request: {e}" + ) + + +@tool() +async def github_list_commits( + owner: str, + repo: str, + sha: str | None = None, + path: str | None = None, + author: str | None = None, + per_page: int = 30, +) -> GitHubCommitListResponse: + """List commits in a GitHub repository. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + sha: Branch name or commit SHA to list commits from. Defaults to the default branch. + path: Only commits containing this file path will be returned. + author: GitHub login or email to filter commits by author. + per_page: Number of commits per page (max 100). + + Returns: + List of commits in the repository. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubCommitListResponse(success=False, error=error) + + if per_page < 1: + return GitHubCommitListResponse( + success=False, error="per_page must be at least 1" + ) + + params: dict = {"per_page": min(per_page, 100)} + if sha: + params["sha"] = sha + if path: + params["path"] = path + if author: + params["author"] = author + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/commits", + headers=headers, + params=params, + ) + response.raise_for_status() + commits_json = response.json() + + commits = [ + GitHubCommitData( + sha=commit["sha"], + message=commit.get("commit", {}).get("message", ""), + author=( + commit.get("author", {}).get("login") + if commit.get("author") + else commit.get("commit", {}).get("author", {}).get("name") + ), + date=commit.get("commit", {}).get("author", {}).get("date"), + url=commit.get("html_url"), + ) + for commit in commits_json + ] + + logger.info("Listed %d GitHub commits for %s/%s", len(commits), owner, repo) + + return GitHubCommitListResponse( + success=True, + data=GitHubCommitListData(commits=commits, total=len(commits)), + ) + except Exception as e: + logger.exception("Failed to list GitHub commits for %s/%s", owner, repo) + return GitHubCommitListResponse( + success=False, error=f"Failed to list commits: {e}" + ) + + +@tool() +async def github_list_releases( + owner: str, + repo: str, + per_page: int = 30, +) -> GitHubReleaseListResponse: + """List releases in a GitHub repository, ordered by creation date descending. + + Args: + owner: The repository owner (user or organization). + repo: The repository name. + per_page: Number of releases per page (max 100). + + Returns: + List of releases in the repository. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return GitHubReleaseListResponse(success=False, error=error) + + if per_page < 1: + return GitHubReleaseListResponse( + success=False, error="per_page must be at least 1" + ) + + params = {"per_page": min(per_page, 100)} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/releases", + headers=headers, + params=params, + ) + response.raise_for_status() + releases_json = response.json() + + releases = [ + GitHubReleaseData( + id=release["id"], + tag_name=release["tag_name"], + name=release.get("name"), + body=release.get("body"), + draft=release.get("draft", False), + prerelease=release.get("prerelease", False), + published_at=release.get("published_at"), + url=release.get("html_url"), + ) + for release in releases_json + ] + + logger.info("Listed %d GitHub releases for %s/%s", len(releases), owner, repo) + + return GitHubReleaseListResponse( + success=True, + data=GitHubReleaseListData(releases=releases, total=len(releases)), + ) + except Exception as e: + logger.exception("Failed to list GitHub releases for %s/%s", owner, repo) + return GitHubReleaseListResponse( + success=False, error=f"Failed to list releases: {e}" + ) diff --git a/src/tools/project_management/jira.py b/src/tools/project_management/jira.py new file mode 100644 index 0000000..47a402b --- /dev/null +++ b/src/tools/project_management/jira.py @@ -0,0 +1,428 @@ +"""Jira project management tools for issue tracking and management. + +Uses the Jira REST API via the jira Python library. Requires JIRA_URL, +JIRA_USERNAME, and JIRA_API_TOKEN environment variables. + +API Reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/ +""" + +from __future__ import annotations + +import logging +from typing import cast + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + JiraCommentData, + JiraCommentResponse, + JiraIssueData, + JiraIssueListData, + JiraIssueListResponse, + JiraIssueResponse, + JiraProjectData, + JiraProjectListData, + JiraProjectListResponse, + JiraTransitionData, + JiraTransitionListData, + JiraTransitionListResponse, + JiraTransitionResponse, +) + +try: + from jira import JIRA, Issue +except ImportError as err: + raise ImportError( + "jira is required for Jira tools. Install with: pip install jira" + ) from err + +logger = logging.getLogger("humcp.tools.jira") + + +async def _get_jira_client() -> tuple[JIRA | None, str | None, str | None]: + """Create a Jira client from environment variables. + + Returns: + A tuple of (client, server_url, error_message). + """ + server_url = await resolve_credential("JIRA_URL") + username = await resolve_credential("JIRA_USERNAME") + api_token = await resolve_credential("JIRA_API_TOKEN") + + if not server_url: + return None, None, "Jira URL not configured. Set JIRA_URL environment variable." + if not username: + return ( + None, + None, + "Jira username not configured. Set JIRA_USERNAME environment variable.", + ) + if not api_token: + return ( + None, + None, + "Jira API token not configured. Set JIRA_API_TOKEN environment variable.", + ) + + client = JIRA(server=server_url, basic_auth=(username, api_token)) + return client, server_url, None + + +def _issue_to_data(issue: Issue, server_url: str | None) -> JiraIssueData: + """Convert a Jira Issue object to JiraIssueData. + + Args: + issue: A Jira Issue object. + server_url: The Jira server base URL. + + Returns: + A JiraIssueData Pydantic model. + """ + priority_name = None + if hasattr(issue.fields, "priority") and issue.fields.priority: + priority_name = issue.fields.priority.name + + labels: list[str] = [] + if hasattr(issue.fields, "labels") and issue.fields.labels: + labels = list(issue.fields.labels) + + return JiraIssueData( + key=issue.key, + project=issue.fields.project.key + if hasattr(issue.fields, "project") and issue.fields.project + else None, + issue_type=issue.fields.issuetype.name + if hasattr(issue.fields, "issuetype") and issue.fields.issuetype + else None, + summary=issue.fields.summary, + description=issue.fields.description or None, + status=issue.fields.status.name + if hasattr(issue.fields, "status") and issue.fields.status + else None, + priority=priority_name, + assignee=( + issue.fields.assignee.displayName + if hasattr(issue.fields, "assignee") and issue.fields.assignee + else None + ), + reporter=( + issue.fields.reporter.displayName + if hasattr(issue.fields, "reporter") and issue.fields.reporter + else None + ), + labels=labels, + url=f"{server_url}/browse/{issue.key}" if server_url else None, + ) + + +@tool() +async def jira_get_issue(issue_key: str) -> JiraIssueResponse: + """Retrieve details of a specific Jira issue by its key (e.g., PROJ-123). + + Args: + issue_key: The Jira issue key to retrieve (e.g., PROJ-123). + + Returns: + Issue details including key, summary, description, status, priority, assignee, and labels. + """ + try: + client, server_url, error = await _get_jira_client() + if error or client is None: + return JiraIssueResponse(success=False, error=error) + + issue = cast(Issue, client.issue(issue_key)) + data = _issue_to_data(issue, server_url) + + return JiraIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Jira issue %s", issue_key) + return JiraIssueResponse(success=False, error=f"Failed to get issue: {e}") + + +@tool() +async def jira_create_issue( + project_key: str, + summary: str, + description: str = "", + issue_type: str = "Task", + priority: str | None = None, + assignee: str | None = None, + labels: list[str] | None = None, +) -> JiraIssueResponse: + """Create a new issue in a Jira project. + + Args: + project_key: The project key to create the issue in (e.g., PROJ). + summary: The issue summary/title. + description: The issue description. + issue_type: The type of issue (e.g., Task, Bug, Story, Epic). + priority: Optional priority name (e.g., High, Medium, Low). + assignee: Optional assignee account ID or username. + labels: Optional list of label names. + + Returns: + Details of the newly created issue. + """ + try: + client, server_url, error = await _get_jira_client() + if error or client is None: + return JiraIssueResponse(success=False, error=error) + + issue_dict: dict = { + "project": {"key": project_key}, + "summary": summary, + "description": description, + "issuetype": {"name": issue_type}, + } + if priority: + issue_dict["priority"] = {"name": priority} + if assignee: + issue_dict["assignee"] = {"name": assignee} + if labels: + issue_dict["labels"] = labels + + new_issue = client.create_issue(fields=issue_dict) + issue = cast(Issue, client.issue(new_issue.key)) + + logger.info("Created Jira issue %s in project %s", new_issue.key, project_key) + + data = _issue_to_data(issue, server_url) + + return JiraIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Jira issue in project %s", project_key) + return JiraIssueResponse(success=False, error=f"Failed to create issue: {e}") + + +@tool() +async def jira_search_issues( + jql: str, + max_results: int = 50, +) -> JiraIssueListResponse: + """Search for Jira issues using JQL (Jira Query Language). + + Args: + jql: The JQL query string (e.g., "project = PROJ AND status = Open"). + max_results: Maximum number of results to return (max 100). + + Returns: + List of issues matching the JQL query. + """ + try: + client, server_url, error = await _get_jira_client() + if error or client is None: + return JiraIssueListResponse(success=False, error=error) + + if max_results < 1: + return JiraIssueListResponse( + success=False, error="max_results must be at least 1" + ) + + issues = client.search_issues(jql, maxResults=min(max_results, 100)) + + issue_list = [ + _issue_to_data(cast(Issue, issue), server_url) for issue in issues + ] + + logger.info("Jira search returned %d results for JQL: %s", len(issue_list), jql) + + return JiraIssueListResponse( + success=True, + data=JiraIssueListData(issues=issue_list, total=len(issue_list)), + ) + except Exception as e: + logger.exception("Failed to search Jira issues with JQL: %s", jql) + return JiraIssueListResponse( + success=False, error=f"Failed to search issues: {e}" + ) + + +@tool() +async def jira_transition_issue( + issue_key: str, + transition_name: str, +) -> JiraTransitionResponse: + """Transition a Jira issue to a new status (e.g., move from "To Do" to "In Progress"). + + Args: + issue_key: The Jira issue key (e.g., PROJ-123). + transition_name: The name of the transition to perform (e.g., "Start Progress", "Done"). + + Returns: + Details of the transition that was performed. + """ + try: + client, _server_url, error = await _get_jira_client() + if error or client is None: + return JiraTransitionResponse(success=False, error=error) + + transitions = client.transitions(issue_key) + target_transition = None + for t in transitions: + if t["name"].lower() == transition_name.lower(): + target_transition = t + break + + if not target_transition: + available = [t["name"] for t in transitions] + return JiraTransitionResponse( + success=False, + error=f"Transition '{transition_name}' not found. Available transitions: {', '.join(available)}", + ) + + client.transition_issue(issue_key, target_transition["id"]) + + logger.info("Transitioned Jira issue %s via '%s'", issue_key, transition_name) + + data = JiraTransitionData( + id=str(target_transition["id"]), + name=target_transition["name"], + to_status=target_transition.get("to", {}).get("name"), + ) + + return JiraTransitionResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to transition Jira issue %s", issue_key) + return JiraTransitionResponse( + success=False, error=f"Failed to transition issue: {e}" + ) + + +@tool() +async def jira_list_transitions( + issue_key: str, +) -> JiraTransitionListResponse: + """List all available transitions for a Jira issue. + + Use this to discover what status changes are possible for an issue before + calling jira_transition_issue. The available transitions depend on the + issue's current status and the project's workflow configuration. + + Args: + issue_key: The Jira issue key (e.g., PROJ-123). + + Returns: + List of available transitions with their IDs, names, and target statuses. + """ + try: + client, _server_url, error = await _get_jira_client() + if error or client is None: + return JiraTransitionListResponse(success=False, error=error) + + transitions = client.transitions(issue_key) + + transition_list = [ + JiraTransitionData( + id=str(t["id"]), + name=t["name"], + to_status=t.get("to", {}).get("name"), + ) + for t in transitions + ] + + logger.info( + "Listed %d transitions for Jira issue %s", + len(transition_list), + issue_key, + ) + + return JiraTransitionListResponse( + success=True, + data=JiraTransitionListData( + issue_key=issue_key, + transitions=transition_list, + total=len(transition_list), + ), + ) + except Exception as e: + logger.exception("Failed to list transitions for Jira issue %s", issue_key) + return JiraTransitionListResponse( + success=False, error=f"Failed to list transitions: {e}" + ) + + +@tool() +async def jira_add_comment( + issue_key: str, + body: str, +) -> JiraCommentResponse: + """Add a comment to a Jira issue. + + Args: + issue_key: The Jira issue key (e.g., PROJ-123). + body: The comment body text. + + Returns: + Details of the newly created comment. + """ + try: + client, server_url, error = await _get_jira_client() + if error or client is None: + return JiraCommentResponse(success=False, error=error) + + comment = client.add_comment(issue_key, body) + + logger.info("Added comment to Jira issue %s", issue_key) + + data = JiraCommentData( + id=comment.id, + body=comment.body, + author=( + comment.author.displayName + if hasattr(comment, "author") and comment.author + else None + ), + created=str(comment.created) if hasattr(comment, "created") else None, + url=f"{server_url}/browse/{issue_key}?focusedCommentId={comment.id}" + if server_url + else None, + ) + + return JiraCommentResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to add comment to Jira issue %s", issue_key) + return JiraCommentResponse(success=False, error=f"Failed to add comment: {e}") + + +@tool() +async def jira_list_projects() -> JiraProjectListResponse: + """List all Jira projects accessible to the authenticated user. + + Returns: + List of Jira projects with their keys, names, and leads. + """ + try: + client, server_url, error = await _get_jira_client() + if error or client is None: + return JiraProjectListResponse(success=False, error=error) + + projects = client.projects() + + project_list = [ + JiraProjectData( + key=p.key, + name=p.name, + project_type=getattr(p, "projectTypeKey", None), + lead=getattr(p, "lead", {}).get("displayName") + if isinstance(getattr(p, "lead", None), dict) + else ( + p.lead.displayName + if hasattr(p, "lead") and p.lead and hasattr(p.lead, "displayName") + else None + ), + url=f"{server_url}/browse/{p.key}" if server_url else None, + ) + for p in projects + ] + + logger.info("Listed %d Jira projects", len(project_list)) + + return JiraProjectListResponse( + success=True, + data=JiraProjectListData(projects=project_list, total=len(project_list)), + ) + except Exception as e: + logger.exception("Failed to list Jira projects") + return JiraProjectListResponse( + success=False, error=f"Failed to list projects: {e}" + ) diff --git a/src/tools/project_management/linear.py b/src/tools/project_management/linear.py new file mode 100644 index 0000000..8517f90 --- /dev/null +++ b/src/tools/project_management/linear.py @@ -0,0 +1,489 @@ +"""Linear project management tools using the Linear GraphQL API. + +Requires a LINEAR_API_KEY environment variable (personal API key or OAuth2 token). + +API Reference: https://linear.app/developers/graphql +""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + LinearIssueData, + LinearIssueListData, + LinearIssueListResponse, + LinearIssueResponse, + LinearTeamData, + LinearTeamListData, + LinearTeamListResponse, +) + +logger = logging.getLogger("humcp.tools.linear") + +LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql" + + +async def _execute_graphql( + api_key: str, + query: str, + variables: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Execute a GraphQL query against the Linear API. + + Args: + api_key: Linear API key. + query: GraphQL query string. + variables: Optional query variables. + + Returns: + The 'data' portion of the GraphQL response. + + Raises: + Exception: If the request fails or contains GraphQL errors. + """ + headers = {"Authorization": api_key, "Content-Type": "application/json"} + payload: dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + LINEAR_GRAPHQL_ENDPOINT, json=payload, headers=headers + ) + response.raise_for_status() + + result = response.json() + if "errors" in result: + raise Exception(f"GraphQL errors: {result['errors']}") + + return result.get("data", {}) + + +def _parse_issue(issue: dict) -> LinearIssueData: + """Parse a Linear issue node into a LinearIssueData model. + + Args: + issue: Raw issue dict from the Linear GraphQL API. + + Returns: + Parsed LinearIssueData. + """ + labels = [] + label_nodes = ( + issue.get("labels", {}).get("nodes", []) if issue.get("labels") else [] + ) + for label in label_nodes: + if label.get("name"): + labels.append(label["name"]) + + return LinearIssueData( + id=issue["id"], + identifier=issue.get("identifier"), + title=issue["title"], + description=issue.get("description"), + status=issue.get("state", {}).get("name") if issue.get("state") else None, + assignee=issue.get("assignee", {}).get("name") + if issue.get("assignee") + else None, + url=issue.get("url"), + priority=issue.get("priority"), + labels=labels, + ) + + +# Common GraphQL fragment for issue fields +_ISSUE_FIELDS = """ + id + identifier + title + description + url + priority + state { name } + assignee { name } + labels { nodes { name } } +""" + + +@tool() +async def linear_create_issue( + title: str, + description: str, + team_id: str, + project_id: str | None = None, + assignee_id: str | None = None, + priority: int | None = None, + label_ids: list[str] | None = None, +) -> LinearIssueResponse: + """Create a new issue in Linear. + + Args: + title: The title of the new issue. + description: The description of the new issue (Markdown supported). + team_id: The ID of the team to create the issue in. + project_id: Optional project ID to associate with the issue. + assignee_id: Optional assignee user ID. + priority: Optional priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low). + label_ids: Optional list of label IDs to apply. + + Returns: + Details of the newly created Linear issue. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearIssueResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + query = f""" + mutation IssueCreate($input: IssueCreateInput!) {{ + issueCreate(input: $input) {{ + success + issue {{ + {_ISSUE_FIELDS} + }} + }} + }} + """ + + input_vars: dict[str, Any] = { + "title": title, + "description": description, + "teamId": team_id, + } + if project_id is not None: + input_vars["projectId"] = project_id + if assignee_id is not None: + input_vars["assigneeId"] = assignee_id + if priority is not None: + input_vars["priority"] = priority + if label_ids: + input_vars["labelIds"] = label_ids + + response = await _execute_graphql(api_key, query, {"input": input_vars}) + + if not response.get("issueCreate", {}).get("success"): + return LinearIssueResponse( + success=False, error="Linear issue creation failed." + ) + + issue = response["issueCreate"]["issue"] + logger.info("Created Linear issue %s: %s", issue["id"], issue["title"]) + + data = _parse_issue(issue) + + return LinearIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Linear issue") + return LinearIssueResponse(success=False, error=f"Failed to create issue: {e}") + + +@tool() +async def linear_get_issue(issue_id: str) -> LinearIssueResponse: + """Retrieve details of a specific Linear issue by its ID. + + Args: + issue_id: The unique identifier of the Linear issue. + + Returns: + Issue details including title, description, status, priority, assignee, and labels. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearIssueResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + query = f""" + query IssueDetails($issueId: String!) {{ + issue(id: $issueId) {{ + {_ISSUE_FIELDS} + }} + }} + """ + + response = await _execute_graphql(api_key, query, {"issueId": issue_id}) + + issue = response.get("issue") + if not issue: + return LinearIssueResponse( + success=False, + error=f"Issue with ID {issue_id} not found.", + ) + + data = _parse_issue(issue) + + return LinearIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Linear issue %s", issue_id) + return LinearIssueResponse(success=False, error=f"Failed to get issue: {e}") + + +@tool() +async def linear_update_issue( + issue_id: str, + title: str | None = None, + description: str | None = None, + state_id: str | None = None, + assignee_id: str | None = None, + priority: int | None = None, + label_ids: list[str] | None = None, +) -> LinearIssueResponse: + """Update an existing Linear issue. + + Args: + issue_id: The ID of the issue to update. + title: New issue title. + description: New issue description (Markdown supported). + state_id: New workflow state ID (use linear_list_issues to see available states). + assignee_id: New assignee user ID. Pass empty string to unassign. + priority: New priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low). + label_ids: New list of label IDs (replaces existing labels). + + Returns: + Updated issue details. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearIssueResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + input_vars: dict[str, Any] = {} + if title is not None: + input_vars["title"] = title + if description is not None: + input_vars["description"] = description + if state_id is not None: + input_vars["stateId"] = state_id + if assignee_id is not None: + input_vars["assigneeId"] = assignee_id if assignee_id else None + if priority is not None: + input_vars["priority"] = priority + if label_ids is not None: + input_vars["labelIds"] = label_ids + + if not input_vars: + return LinearIssueResponse( + success=False, error="At least one field must be provided to update." + ) + + query = f""" + mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {{ + issueUpdate(id: $id, input: $input) {{ + success + issue {{ + {_ISSUE_FIELDS} + }} + }} + }} + """ + + response = await _execute_graphql( + api_key, query, {"id": issue_id, "input": input_vars} + ) + + if not response.get("issueUpdate", {}).get("success"): + return LinearIssueResponse( + success=False, error="Linear issue update failed." + ) + + issue = response["issueUpdate"]["issue"] + logger.info("Updated Linear issue %s", issue_id) + + data = _parse_issue(issue) + + return LinearIssueResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Linear issue %s", issue_id) + return LinearIssueResponse(success=False, error=f"Failed to update issue: {e}") + + +@tool() +async def linear_list_issues( + team_id: str | None = None, + project_id: str | None = None, + limit: int = 25, +) -> LinearIssueListResponse: + """List issues, optionally filtered by team or project. + + Args: + team_id: Optional team ID to filter issues by. + project_id: Optional project ID to filter issues by. + limit: Maximum number of issues to return (max 50). + + Returns: + List of issues matching the filters. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearIssueListResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + if limit < 1: + return LinearIssueListResponse( + success=False, error="limit must be at least 1" + ) + + # Build filter + filter_parts = [] + if team_id: + filter_parts.append(f'team: {{ id: {{ eq: "{team_id}" }} }}') + if project_id: + filter_parts.append(f'project: {{ id: {{ eq: "{project_id}" }} }}') + + filter_str = ", ".join(filter_parts) + filter_arg = f", filter: {{ {filter_str} }}" if filter_str else "" + + query = f""" + query ListIssues($first: Int) {{ + issues(first: $first{filter_arg}) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + """ + + response = await _execute_graphql(api_key, query, {"first": min(limit, 50)}) + + nodes = response.get("issues", {}).get("nodes", []) + issues = [_parse_issue(node) for node in nodes] + + logger.info("Listed %d Linear issues", len(issues)) + + return LinearIssueListResponse( + success=True, + data=LinearIssueListData(issues=issues, total=len(issues)), + ) + except Exception as e: + logger.exception("Failed to list Linear issues") + return LinearIssueListResponse( + success=False, error=f"Failed to list issues: {e}" + ) + + +@tool() +async def linear_search_issues( + query_text: str, + limit: int = 25, +) -> LinearIssueListResponse: + """Search for Linear issues by text query. + + Args: + query_text: The search query string to match against issue titles and descriptions. + limit: Maximum number of results to return (max 50). + + Returns: + List of issues matching the search query. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearIssueListResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + if limit < 1: + return LinearIssueListResponse( + success=False, error="limit must be at least 1" + ) + + query = f""" + query SearchIssues($term: String!, $first: Int) {{ + searchIssues(term: $term, first: $first) {{ + nodes {{ + {_ISSUE_FIELDS} + }} + }} + }} + """ + + response = await _execute_graphql( + api_key, query, {"term": query_text, "first": min(limit, 50)} + ) + + nodes = response.get("searchIssues", {}).get("nodes", []) + issues = [_parse_issue(node) for node in nodes] + + logger.info( + "Linear search returned %d results for: %s", len(issues), query_text + ) + + return LinearIssueListResponse( + success=True, + data=LinearIssueListData(issues=issues, total=len(issues)), + ) + except Exception as e: + logger.exception("Failed to search Linear issues for: %s", query_text) + return LinearIssueListResponse( + success=False, error=f"Failed to search issues: {e}" + ) + + +@tool() +async def linear_list_teams() -> LinearTeamListResponse: + """List all teams in the Linear workspace. + + Returns: + List of teams with their IDs, names, and key prefixes. + """ + try: + api_key = await resolve_credential("LINEAR_API_KEY") + if not api_key: + return LinearTeamListResponse( + success=False, + error="Linear API key not configured. Set LINEAR_API_KEY environment variable.", + ) + + query = """ + query Teams { + teams { + nodes { + id + name + key + description + } + } + } + """ + + response = await _execute_graphql(api_key, query) + + nodes = response.get("teams", {}).get("nodes", []) + teams = [ + LinearTeamData( + id=team["id"], + name=team["name"], + key=team["key"], + description=team.get("description"), + ) + for team in nodes + ] + + logger.info("Listed %d Linear teams", len(teams)) + + return LinearTeamListResponse( + success=True, + data=LinearTeamListData(teams=teams, total=len(teams)), + ) + except Exception as e: + logger.exception("Failed to list Linear teams") + return LinearTeamListResponse(success=False, error=f"Failed to list teams: {e}") diff --git a/src/tools/project_management/notion.py b/src/tools/project_management/notion.py new file mode 100644 index 0000000..cdd5cc4 --- /dev/null +++ b/src/tools/project_management/notion.py @@ -0,0 +1,505 @@ +"""Notion tools for page management, search, database queries, and block operations. + +Uses the Notion API (version 2022-06-28). Requires a NOTION_API_KEY environment +variable (internal integration token). + +API Reference: https://developers.notion.com/reference +""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + NotionBlockData, + NotionBlockListData, + NotionBlockListResponse, + NotionDatabaseQueryData, + NotionDatabaseQueryResponse, + NotionPageData, + NotionPageListData, + NotionPageListResponse, + NotionPageResponse, +) + +logger = logging.getLogger("humcp.tools.notion") + +NOTION_API_BASE = "https://api.notion.com/v1" +NOTION_VERSION = "2022-06-28" + + +async def _get_headers() -> tuple[dict[str, str] | None, str | None]: + """Build Notion API headers from environment variables. + + Returns: + A tuple of (headers_dict, error_message). + """ + api_key = await resolve_credential("NOTION_API_KEY") + if not api_key: + return ( + None, + "Notion API key not configured. Set NOTION_API_KEY environment variable.", + ) + + return { + "Authorization": f"Bearer {api_key}", + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + }, None + + +def _extract_title(page: dict[str, Any]) -> str: + """Extract the title from a Notion page properties dict. + + Args: + page: The Notion page response dict. + + Returns: + The page title string, or "Untitled". + """ + properties = page.get("properties", {}) + + # Try common title property names + for prop_name in ("Name", "Title", "title"): + prop = properties.get(prop_name, {}) + if prop.get("title") and len(prop["title"]) > 0: + return prop["title"][0].get("text", {}).get("content", "Untitled") + + # Fall back to searching for any title-type property + for prop in properties.values(): + if isinstance(prop, dict) and prop.get("type") == "title": + title_list = prop.get("title", []) + if title_list: + return title_list[0].get("text", {}).get("content", "Untitled") + + return "Untitled" + + +def _extract_rich_text(rich_text_list: list[dict]) -> str: + """Extract plain text from a Notion rich_text array. + + Args: + rich_text_list: List of rich_text objects from the Notion API. + + Returns: + Concatenated plain text content. + """ + return "".join(item.get("text", {}).get("content", "") for item in rich_text_list) + + +def _page_to_data(page: dict[str, Any]) -> NotionPageData: + """Convert a Notion page API response to NotionPageData. + + Args: + page: Raw page dict from the Notion API. + + Returns: + Parsed NotionPageData. + """ + parent = page.get("parent", {}) + parent_id = parent.get("database_id") or parent.get("page_id") + + return NotionPageData( + id=page["id"], + title=_extract_title(page), + url=page.get("url"), + parent_id=parent_id, + created_time=page.get("created_time"), + last_edited_time=page.get("last_edited_time"), + ) + + +@tool() +async def notion_get_page(page_id: str) -> NotionPageResponse: + """Retrieve a Notion page by its ID. + + Args: + page_id: The ID of the Notion page to retrieve (UUID format, dashes optional). + + Returns: + Page details including title, URL, parent, and timestamps. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionPageResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{NOTION_API_BASE}/pages/{page_id}", + headers=headers, + ) + response.raise_for_status() + page = response.json() + + data = _page_to_data(page) + + return NotionPageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Notion page %s", page_id) + return NotionPageResponse(success=False, error=f"Failed to get page: {e}") + + +@tool() +async def notion_create_page( + parent_id: str, + title: str, + content: str = "", + is_database: bool = False, +) -> NotionPageResponse: + """Create a new page in Notion under a parent page or database. + + Args: + parent_id: The ID of the parent page or database. + title: The title of the new page. + content: Optional text content for the page body. + is_database: Set to True if parent_id is a database ID instead of a page ID. + + Returns: + Details of the newly created page. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionPageResponse(success=False, error=error) + + if is_database: + parent_obj: dict[str, str] = {"database_id": parent_id} + title_prop = "Name" + else: + parent_obj = {"page_id": parent_id} + title_prop = "title" + + payload: dict[str, Any] = { + "parent": parent_obj, + "properties": { + title_prop: { + "title": [{"text": {"content": title}}], + }, + }, + } + + children: list[dict[str, Any]] = [] + if content: + children.append( + { + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [{"type": "text", "text": {"content": content}}], + }, + } + ) + payload["children"] = children + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{NOTION_API_BASE}/pages", + json=payload, + headers=headers, + ) + + # If page_id parent fails and is_database was not set, retry with database_id + if response.status_code == 400 and not is_database: + payload["parent"] = {"database_id": parent_id} + payload["properties"] = { + "Name": { + "title": [{"text": {"content": title}}], + }, + } + response = await client.post( + f"{NOTION_API_BASE}/pages", + json=payload, + headers=headers, + ) + + response.raise_for_status() + page = response.json() + + logger.info("Created Notion page '%s' (ID: %s)", title, page["id"]) + + data = _page_to_data(page) + + return NotionPageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Notion page '%s'", title) + return NotionPageResponse(success=False, error=f"Failed to create page: {e}") + + +@tool() +async def notion_update_page( + page_id: str, + title: str | None = None, + archived: bool | None = None, +) -> NotionPageResponse: + """Update a Notion page's properties. + + Args: + page_id: The ID of the page to update. + title: New page title. Pass None to keep unchanged. + archived: Set to True to archive the page, False to unarchive. + + Returns: + Updated page details. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionPageResponse(success=False, error=error) + + payload: dict[str, Any] = {} + + if title is not None: + # We need to find the title property name first + async with httpx.AsyncClient(timeout=30.0) as client: + get_resp = await client.get( + f"{NOTION_API_BASE}/pages/{page_id}", + headers=headers, + ) + get_resp.raise_for_status() + existing = get_resp.json() + + title_prop_name = "title" + for prop_name, prop_val in existing.get("properties", {}).items(): + if isinstance(prop_val, dict) and prop_val.get("type") == "title": + title_prop_name = prop_name + break + + payload["properties"] = { + title_prop_name: { + "title": [{"text": {"content": title}}], + }, + } + + if archived is not None: + payload["archived"] = archived + + if not payload: + return NotionPageResponse( + success=False, error="At least one field must be provided to update." + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.patch( + f"{NOTION_API_BASE}/pages/{page_id}", + json=payload, + headers=headers, + ) + response.raise_for_status() + page = response.json() + + logger.info("Updated Notion page %s", page_id) + + data = _page_to_data(page) + + return NotionPageResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Notion page %s", page_id) + return NotionPageResponse(success=False, error=f"Failed to update page: {e}") + + +@tool() +async def notion_search( + query: str, + page_size: int = 25, +) -> NotionPageListResponse: + """Search for pages in Notion by title or content. + + Args: + query: The search query string. + page_size: Maximum number of results to return (max 100). + + Returns: + List of pages matching the search query. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionPageListResponse(success=False, error=error) + + if page_size < 1: + return NotionPageListResponse( + success=False, error="page_size must be at least 1" + ) + + payload = { + "query": query, + "page_size": min(page_size, 100), + "filter": {"value": "page", "property": "object"}, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{NOTION_API_BASE}/search", + json=payload, + headers=headers, + ) + response.raise_for_status() + data = response.json() + + pages = [_page_to_data(result) for result in data.get("results", [])] + + logger.info("Notion search returned %d results for: %s", len(pages), query) + + return NotionPageListResponse( + success=True, + data=NotionPageListData( + pages=pages, + total=len(pages), + has_more=data.get("has_more", False), + next_cursor=data.get("next_cursor"), + ), + ) + except Exception as e: + logger.exception("Failed to search Notion for: %s", query) + return NotionPageListResponse(success=False, error=f"Failed to search: {e}") + + +@tool() +async def notion_query_database( + database_id: str, + page_size: int = 25, + start_cursor: str | None = None, +) -> NotionDatabaseQueryResponse: + """Query a Notion database to retrieve its pages. + + Args: + database_id: The ID of the Notion database to query. + page_size: Maximum number of results to return (max 100). + start_cursor: Optional cursor for pagination from a previous query. + + Returns: + Pages in the database matching the query. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionDatabaseQueryResponse(success=False, error=error) + + if page_size < 1: + return NotionDatabaseQueryResponse( + success=False, error="page_size must be at least 1" + ) + + payload: dict[str, Any] = { + "page_size": min(page_size, 100), + } + if start_cursor: + payload["start_cursor"] = start_cursor + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{NOTION_API_BASE}/databases/{database_id}/query", + json=payload, + headers=headers, + ) + response.raise_for_status() + data = response.json() + + pages = [_page_to_data(result) for result in data.get("results", [])] + + logger.info( + "Notion database query returned %d results for database %s", + len(pages), + database_id, + ) + + return NotionDatabaseQueryResponse( + success=True, + data=NotionDatabaseQueryData( + pages=pages, + total=len(pages), + has_more=data.get("has_more", False), + next_cursor=data.get("next_cursor"), + ), + ) + except Exception as e: + logger.exception("Failed to query Notion database %s", database_id) + return NotionDatabaseQueryResponse( + success=False, error=f"Failed to query database: {e}" + ) + + +@tool() +async def notion_get_block_children( + block_id: str, + page_size: int = 50, + start_cursor: str | None = None, +) -> NotionBlockListResponse: + """Get the children blocks of a Notion block or page. Useful for reading page content. + + Args: + block_id: The ID of the block or page whose children to retrieve. + page_size: Maximum number of blocks to return (max 100). + start_cursor: Optional cursor for pagination. + + Returns: + List of child blocks with their types and text content. + """ + try: + headers, error = await _get_headers() + if error or headers is None: + return NotionBlockListResponse(success=False, error=error) + + if page_size < 1: + return NotionBlockListResponse( + success=False, error="page_size must be at least 1" + ) + + params: dict[str, Any] = {"page_size": min(page_size, 100)} + if start_cursor: + params["start_cursor"] = start_cursor + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{NOTION_API_BASE}/blocks/{block_id}/children", + headers=headers, + params=params, + ) + response.raise_for_status() + data = response.json() + + blocks = [] + for block in data.get("results", []): + block_type = block.get("type", "unknown") + content = None + + # Extract text content from common block types + type_data = block.get(block_type, {}) + if isinstance(type_data, dict): + rich_text = type_data.get("rich_text", []) + if rich_text: + content = _extract_rich_text(rich_text) + elif block_type == "child_page": + content = type_data.get("title", "") + + blocks.append( + NotionBlockData( + id=block["id"], + block_type=block_type, + content=content, + has_children=block.get("has_children", False), + ) + ) + + logger.info("Retrieved %d blocks for Notion block %s", len(blocks), block_id) + + return NotionBlockListResponse( + success=True, + data=NotionBlockListData( + blocks=blocks, + total=len(blocks), + has_more=data.get("has_more", False), + next_cursor=data.get("next_cursor"), + ), + ) + except Exception as e: + logger.exception("Failed to get blocks for Notion block %s", block_id) + return NotionBlockListResponse( + success=False, error=f"Failed to get blocks: {e}" + ) diff --git a/src/tools/project_management/schemas.py b/src/tools/project_management/schemas.py new file mode 100644 index 0000000..7099dac --- /dev/null +++ b/src/tools/project_management/schemas.py @@ -0,0 +1,1072 @@ +"""Pydantic output schemas for project management tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Shared Issue / Task Schemas +# ============================================================================= + + +class IssueData(BaseModel): + """A single issue or task from any project management service.""" + + id: str = Field(..., description="Unique identifier of the issue") + title: str = Field(..., description="Title or summary of the issue") + description: str | None = Field( + None, description="Description or body of the issue" + ) + status: str | None = Field(None, description="Current status of the issue") + assignee: str | None = Field( + None, description="Assignee display name or identifier" + ) + url: str | None = Field(None, description="URL to view the issue in the browser") + + +class IssueListData(BaseModel): + """A list of issues or tasks.""" + + issues: list[IssueData] = Field(default_factory=list, description="List of issues") + total: int | None = Field(None, description="Total number of matching issues") + + +# ============================================================================= +# Jira-Specific Schemas +# ============================================================================= + + +class JiraIssueData(BaseModel): + """A Jira issue with Jira-specific fields.""" + + key: str = Field(..., description="Jira issue key (e.g., PROJ-123)") + project: str | None = Field(None, description="Project key") + issue_type: str | None = Field( + None, description="Issue type (e.g., Task, Bug, Story)" + ) + summary: str = Field(..., description="Issue summary") + description: str | None = Field(None, description="Issue description") + status: str | None = Field(None, description="Current status") + priority: str | None = Field( + None, description="Issue priority (e.g., High, Medium, Low)" + ) + assignee: str | None = Field(None, description="Assignee display name") + reporter: str | None = Field(None, description="Reporter display name") + labels: list[str] = Field(default_factory=list, description="Issue labels") + url: str | None = Field(None, description="URL to the issue") + + +class JiraIssueListData(BaseModel): + """A list of Jira issues from a JQL search.""" + + issues: list[JiraIssueData] = Field( + default_factory=list, description="List of Jira issues" + ) + total: int | None = Field(None, description="Total number of matching issues") + + +class JiraProjectData(BaseModel): + """A Jira project.""" + + key: str = Field(..., description="Project key (e.g., PROJ)") + name: str = Field(..., description="Project name") + project_type: str | None = Field(None, description="Project type key") + lead: str | None = Field(None, description="Project lead display name") + url: str | None = Field(None, description="URL to the project") + + +class JiraProjectListData(BaseModel): + """A list of Jira projects.""" + + projects: list[JiraProjectData] = Field( + default_factory=list, description="List of Jira projects" + ) + total: int | None = Field(None, description="Total number of projects") + + +class JiraTransitionData(BaseModel): + """A Jira issue transition.""" + + id: str = Field(..., description="Transition ID") + name: str = Field(..., description="Transition name") + to_status: str | None = Field(None, description="Target status name") + + +class JiraTransitionListData(BaseModel): + """A list of available transitions for a Jira issue.""" + + issue_key: str = Field(..., description="The Jira issue key") + transitions: list[JiraTransitionData] = Field( + default_factory=list, description="List of available transitions" + ) + total: int | None = Field(None, description="Total number of available transitions") + + +class JiraCommentData(BaseModel): + """A Jira issue comment.""" + + id: str = Field(..., description="Comment ID") + body: str = Field(..., description="Comment body text") + author: str | None = Field(None, description="Author display name") + created: str | None = Field(None, description="Creation timestamp") + url: str | None = Field(None, description="URL to the comment") + + +# ============================================================================= +# Linear-Specific Schemas +# ============================================================================= + + +class LinearIssueData(BaseModel): + """A Linear issue.""" + + id: str = Field(..., description="Linear issue ID") + identifier: str | None = Field(None, description="Issue identifier (e.g., ENG-123)") + title: str = Field(..., description="Issue title") + description: str | None = Field(None, description="Issue description (Markdown)") + status: str | None = Field(None, description="Issue state name") + assignee: str | None = Field(None, description="Assignee name") + url: str | None = Field(None, description="URL to the issue") + priority: int | None = Field( + None, description="Priority level (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)" + ) + labels: list[str] = Field(default_factory=list, description="Label names") + + +class LinearIssueListData(BaseModel): + """A list of Linear issues.""" + + issues: list[LinearIssueData] = Field( + default_factory=list, description="List of Linear issues" + ) + total: int | None = Field(None, description="Total count of issues") + + +class LinearTeamData(BaseModel): + """A Linear team.""" + + id: str = Field(..., description="Team ID") + name: str = Field(..., description="Team name") + key: str = Field(..., description="Team key prefix (e.g., ENG)") + description: str | None = Field(None, description="Team description") + + +class LinearTeamListData(BaseModel): + """A list of Linear teams.""" + + teams: list[LinearTeamData] = Field( + default_factory=list, description="List of teams" + ) + total: int | None = Field(None, description="Total count of teams") + + +class LinearCycleData(BaseModel): + """A Linear cycle (sprint).""" + + id: str = Field(..., description="Cycle ID") + number: int = Field(..., description="Cycle number within the team") + name: str | None = Field(None, description="Cycle name") + starts_at: str | None = Field(None, description="Cycle start date (ISO 8601)") + ends_at: str | None = Field(None, description="Cycle end date (ISO 8601)") + completed_at: str | None = Field( + None, description="Cycle completion date (ISO 8601)" + ) + progress: float | None = Field( + None, description="Cycle progress as a fraction (0.0 to 1.0)" + ) + scope_count: int | None = Field( + None, description="Total number of issues in the cycle" + ) + completed_count: int | None = Field(None, description="Number of completed issues") + + +class LinearCycleListData(BaseModel): + """A list of Linear cycles.""" + + cycles: list[LinearCycleData] = Field( + default_factory=list, description="List of cycles" + ) + total: int | None = Field(None, description="Total count of cycles") + + +# ============================================================================= +# ClickUp-Specific Schemas +# ============================================================================= + + +class ClickUpTaskData(BaseModel): + """A ClickUp task.""" + + id: str = Field(..., description="ClickUp task ID") + name: str = Field(..., description="Task name") + description: str | None = Field(None, description="Task description") + status: str | None = Field(None, description="Task status") + priority: str | None = Field(None, description="Task priority") + assignees: list[str] = Field( + default_factory=list, description="List of assignee names" + ) + due_date: str | None = Field(None, description="Due date as Unix timestamp in ms") + url: str | None = Field(None, description="URL to the task") + + +class ClickUpTaskListData(BaseModel): + """A list of ClickUp tasks.""" + + tasks: list[ClickUpTaskData] = Field( + default_factory=list, description="List of tasks" + ) + total: int | None = Field(None, description="Total count of tasks") + + +class ClickUpSpaceData(BaseModel): + """A ClickUp space.""" + + id: str = Field(..., description="Space ID") + name: str = Field(..., description="Space name") + private: bool = Field(False, description="Whether the space is private") + + +class ClickUpSpaceListData(BaseModel): + """A list of ClickUp spaces.""" + + spaces: list[ClickUpSpaceData] = Field( + default_factory=list, description="List of spaces" + ) + total: int | None = Field(None, description="Total count of spaces") + + +class ClickUpCommentData(BaseModel): + """A ClickUp task comment.""" + + id: str = Field(..., description="Comment ID") + comment_text: str = Field(..., description="Comment text content") + user: str | None = Field(None, description="Commenter display name") + date: str | None = Field(None, description="Comment timestamp (Unix ms)") + + +class ClickUpCommentListData(BaseModel): + """A list of ClickUp task comments.""" + + comments: list[ClickUpCommentData] = Field( + default_factory=list, description="List of comments" + ) + total: int | None = Field(None, description="Total count of comments") + + +# ============================================================================= +# Todoist-Specific Schemas +# ============================================================================= + + +class TodoistTaskData(BaseModel): + """A Todoist task.""" + + id: str = Field(..., description="Todoist task ID") + content: str = Field(..., description="Task content") + description: str | None = Field(None, description="Task description") + project_id: str | None = Field(None, description="Project ID") + section_id: str | None = Field(None, description="Section ID") + priority: int | None = Field( + None, description="Priority level (1=normal, 4=urgent)" + ) + url: str | None = Field(None, description="Task URL") + due: str | None = Field(None, description="Due date string") + is_completed: bool = Field(False, description="Whether the task is completed") + labels: list[str] = Field(default_factory=list, description="Task label names") + + +class TodoistTaskListData(BaseModel): + """A list of Todoist tasks.""" + + tasks: list[TodoistTaskData] = Field( + default_factory=list, description="List of tasks" + ) + total: int | None = Field(None, description="Total count of tasks") + + +class TodoistProjectData(BaseModel): + """A Todoist project.""" + + id: str = Field(..., description="Project ID") + name: str = Field(..., description="Project name") + color: str | None = Field(None, description="Project color name") + is_favorite: bool = Field(False, description="Whether the project is a favorite") + url: str | None = Field(None, description="Project URL") + + +class TodoistProjectListData(BaseModel): + """A list of Todoist projects.""" + + projects: list[TodoistProjectData] = Field( + default_factory=list, description="List of projects" + ) + total: int | None = Field(None, description="Total count of projects") + + +class TodoistCommentData(BaseModel): + """A Todoist comment.""" + + id: str = Field(..., description="Comment ID") + content: str = Field(..., description="Comment content") + task_id: str | None = Field(None, description="Task ID the comment belongs to") + posted_at: str | None = Field(None, description="Creation timestamp") + + +class TodoistCommentListData(BaseModel): + """A list of Todoist comments.""" + + comments: list[TodoistCommentData] = Field( + default_factory=list, description="List of comments" + ) + total: int | None = Field(None, description="Total count of comments") + + +# ============================================================================= +# Trello-Specific Schemas +# ============================================================================= + + +class TrelloCardData(BaseModel): + """A Trello card.""" + + id: str = Field(..., description="Trello card ID") + name: str = Field(..., description="Card name") + description: str | None = Field(None, description="Card description") + url: str | None = Field(None, description="Card URL") + list_name: str | None = Field(None, description="List name the card belongs to") + labels: list[str] = Field( + default_factory=list, description="Label names on the card" + ) + due: str | None = Field(None, description="Due date (ISO 8601)") + closed: bool = Field(False, description="Whether the card is archived") + + +class TrelloCardListData(BaseModel): + """A list of Trello cards.""" + + cards: list[TrelloCardData] = Field( + default_factory=list, description="List of cards" + ) + total: int | None = Field(None, description="Total number of cards") + + +class TrelloBoardData(BaseModel): + """A Trello board.""" + + id: str = Field(..., description="Board ID") + name: str = Field(..., description="Board name") + description: str | None = Field(None, description="Board description") + url: str | None = Field(None, description="Board URL") + closed: bool = Field(False, description="Whether the board is closed") + + +class TrelloBoardListData(BaseModel): + """A list of Trello boards.""" + + boards: list[TrelloBoardData] = Field( + default_factory=list, description="List of boards" + ) + total: int | None = Field(None, description="Total number of boards") + + +class TrelloListData(BaseModel): + """A Trello list within a board.""" + + id: str = Field(..., description="List ID") + name: str = Field(..., description="List name") + closed: bool = Field(False, description="Whether the list is archived") + + +class TrelloListListData(BaseModel): + """A list of Trello lists.""" + + lists: list[TrelloListData] = Field( + default_factory=list, description="List of Trello lists" + ) + total: int | None = Field(None, description="Total number of lists") + + +# ============================================================================= +# Confluence-Specific Schemas +# ============================================================================= + + +class ConfluencePageData(BaseModel): + """A Confluence page.""" + + id: str = Field(..., description="Page ID") + title: str = Field(..., description="Page title") + space_key: str | None = Field(default=None, description="Space key") + body: str | None = Field( + default=None, description="Page body content (storage format HTML)" + ) + version: int | None = Field(default=None, description="Current version number") + url: str | None = Field(default=None, description="Page URL") + + +class ConfluencePageListData(BaseModel): + """A list of Confluence pages.""" + + pages: list[ConfluencePageData] = Field( + default_factory=list, description="List of pages" + ) + total: int | None = Field(None, description="Total number of pages") + + +class ConfluenceSpaceData(BaseModel): + """A Confluence space.""" + + key: str = Field(..., description="Space key") + name: str = Field(..., description="Space name") + space_type: str | None = Field(None, description="Space type (global or personal)") + url: str | None = Field(None, description="Space URL") + + +class ConfluenceSpaceListData(BaseModel): + """A list of Confluence spaces.""" + + spaces: list[ConfluenceSpaceData] = Field( + default_factory=list, description="List of spaces" + ) + total: int | None = Field(None, description="Total number of spaces") + + +class ConfluenceCommentData(BaseModel): + """A Confluence page comment.""" + + id: str = Field(..., description="Comment ID") + body: str = Field(..., description="Comment body content") + author: str | None = Field(None, description="Author display name") + created: str | None = Field(None, description="Creation timestamp") + + +class ConfluenceCommentListData(BaseModel): + """A list of Confluence comments.""" + + comments: list[ConfluenceCommentData] = Field( + default_factory=list, description="List of comments" + ) + total: int | None = Field(None, description="Total number of comments") + + +# ============================================================================= +# Notion-Specific Schemas +# ============================================================================= + + +class NotionPageData(BaseModel): + """A Notion page.""" + + id: str = Field(..., description="Page ID") + title: str = Field(..., description="Page title") + url: str | None = Field(None, description="Page URL") + parent_id: str | None = Field(None, description="Parent page or database ID") + created_time: str | None = Field(None, description="Creation timestamp (ISO 8601)") + last_edited_time: str | None = Field( + None, description="Last edit timestamp (ISO 8601)" + ) + + +class NotionPageListData(BaseModel): + """A list of Notion pages.""" + + pages: list[NotionPageData] = Field( + default_factory=list, description="List of pages" + ) + total: int | None = Field(None, description="Total number of pages") + has_more: bool = Field(False, description="Whether more results are available") + next_cursor: str | None = Field(None, description="Cursor for next page of results") + + +class NotionDatabaseQueryData(BaseModel): + """Results from a Notion database query.""" + + pages: list[NotionPageData] = Field( + default_factory=list, description="Pages matching the query" + ) + total: int | None = Field(None, description="Number of results returned") + has_more: bool = Field(False, description="Whether more results are available") + next_cursor: str | None = Field(None, description="Cursor for next page of results") + + +class NotionBlockData(BaseModel): + """A Notion block.""" + + id: str = Field(..., description="Block ID") + block_type: str = Field(..., description="Block type (paragraph, heading_1, etc.)") + content: str | None = Field(None, description="Text content of the block") + has_children: bool = Field(False, description="Whether the block has child blocks") + + +class NotionBlockListData(BaseModel): + """A list of Notion blocks.""" + + blocks: list[NotionBlockData] = Field( + default_factory=list, description="List of blocks" + ) + total: int | None = Field(None, description="Number of blocks returned") + has_more: bool = Field(False, description="Whether more results are available") + next_cursor: str | None = Field(None, description="Cursor for next page of results") + + +# ============================================================================= +# Bitbucket-Specific Schemas +# ============================================================================= + + +class BitbucketRepoData(BaseModel): + """A Bitbucket repository.""" + + slug: str = Field(..., description="Repository slug") + name: str = Field(..., description="Repository name") + full_name: str | None = Field(None, description="Full name (workspace/repo)") + description: str | None = Field(None, description="Repository description") + is_private: bool = Field(False, description="Whether the repository is private") + language: str | None = Field(None, description="Primary programming language") + url: str | None = Field(None, description="Repository URL") + + +class BitbucketRepoListData(BaseModel): + """A list of Bitbucket repositories.""" + + repos: list[BitbucketRepoData] = Field( + default_factory=list, description="List of repositories" + ) + total: int | None = Field(None, description="Total number of repositories") + + +class BitbucketPullRequestData(BaseModel): + """A Bitbucket pull request.""" + + id: int = Field(..., description="Pull request ID") + title: str = Field(..., description="Pull request title") + description: str | None = Field(None, description="Pull request description") + state: str | None = Field( + None, description="Pull request state (OPEN, MERGED, DECLINED, SUPERSEDED)" + ) + author: str | None = Field(None, description="Author display name") + source_branch: str | None = Field(None, description="Source branch name") + dest_branch: str | None = Field(None, description="Destination branch name") + url: str | None = Field(None, description="Pull request URL") + created_on: str | None = Field(None, description="Creation timestamp") + + +class BitbucketPullRequestListData(BaseModel): + """A list of Bitbucket pull requests.""" + + pull_requests: list[BitbucketPullRequestData] = Field( + default_factory=list, description="List of pull requests" + ) + total: int | None = Field(None, description="Total number of pull requests") + + +class BitbucketCommitData(BaseModel): + """A Bitbucket commit.""" + + hash: str = Field(..., description="Full commit hash") + message: str = Field(..., description="Commit message") + author: str | None = Field(None, description="Author display name") + date: str | None = Field(None, description="Commit date (ISO 8601)") + url: str | None = Field(None, description="Commit URL") + + +class BitbucketCommitListData(BaseModel): + """A list of Bitbucket commits.""" + + commits: list[BitbucketCommitData] = Field( + default_factory=list, description="List of commits" + ) + total: int | None = Field(None, description="Total number of commits") + + +# ============================================================================= +# GitHub-Specific Schemas +# ============================================================================= + + +class GitHubRepoData(BaseModel): + """A GitHub repository.""" + + name: str = Field(..., description="Repository name") + full_name: str = Field(..., description="Full name (owner/repo)") + description: str | None = Field(None, description="Repository description") + url: str | None = Field(None, description="Repository URL") + stars: int | None = Field(None, description="Star count") + forks: int | None = Field(None, description="Fork count") + language: str | None = Field(None, description="Primary language") + is_private: bool = Field(False, description="Whether the repository is private") + default_branch: str | None = Field(None, description="Default branch name") + open_issues_count: int | None = Field(None, description="Number of open issues") + + +class GitHubIssueData(BaseModel): + """A GitHub issue.""" + + number: int = Field(..., description="Issue number") + title: str = Field(..., description="Issue title") + body: str | None = Field(None, description="Issue body (Markdown)") + state: str | None = Field(None, description="Issue state (open/closed)") + assignee: str | None = Field(None, description="Assignee login") + url: str | None = Field(None, description="Issue HTML URL") + labels: list[str] = Field(default_factory=list, description="Issue label names") + created_at: str | None = Field(None, description="Creation timestamp (ISO 8601)") + updated_at: str | None = Field(None, description="Last update timestamp (ISO 8601)") + + +class GitHubIssueListData(BaseModel): + """A list of GitHub issues.""" + + issues: list[GitHubIssueData] = Field( + default_factory=list, description="List of issues" + ) + total: int | None = Field(None, description="Total number of issues") + + +class GitHubPullRequestData(BaseModel): + """A GitHub pull request.""" + + number: int = Field(..., description="Pull request number") + title: str = Field(..., description="Pull request title") + body: str | None = Field(None, description="Pull request body (Markdown)") + state: str | None = Field(None, description="PR state (open/closed)") + merged: bool = Field(False, description="Whether the PR has been merged") + head_branch: str | None = Field(None, description="Head branch name") + base_branch: str | None = Field(None, description="Base branch name") + user: str | None = Field(None, description="Author login") + url: str | None = Field(None, description="Pull request HTML URL") + created_at: str | None = Field(None, description="Creation timestamp (ISO 8601)") + + +class GitHubPullRequestListData(BaseModel): + """A list of GitHub pull requests.""" + + pull_requests: list[GitHubPullRequestData] = Field( + default_factory=list, description="List of pull requests" + ) + total: int | None = Field(None, description="Total number of pull requests") + + +class GitHubCommitData(BaseModel): + """A GitHub commit.""" + + sha: str = Field(..., description="Commit SHA") + message: str = Field(..., description="Commit message") + author: str | None = Field(None, description="Author login or name") + date: str | None = Field(None, description="Commit date (ISO 8601)") + url: str | None = Field(None, description="Commit HTML URL") + + +class GitHubCommitListData(BaseModel): + """A list of GitHub commits.""" + + commits: list[GitHubCommitData] = Field( + default_factory=list, description="List of commits" + ) + total: int | None = Field(None, description="Total number of commits") + + +class GitHubReleaseData(BaseModel): + """A GitHub release.""" + + id: int = Field(..., description="Release ID") + tag_name: str = Field(..., description="Git tag name") + name: str | None = Field(None, description="Release name") + body: str | None = Field(None, description="Release body (Markdown)") + draft: bool = Field(False, description="Whether this is a draft release") + prerelease: bool = Field(False, description="Whether this is a pre-release") + published_at: str | None = Field( + None, description="Publication timestamp (ISO 8601)" + ) + url: str | None = Field(None, description="Release HTML URL") + + +class GitHubReleaseListData(BaseModel): + """A list of GitHub releases.""" + + releases: list[GitHubReleaseData] = Field( + default_factory=list, description="List of releases" + ) + total: int | None = Field(None, description="Total number of releases") + + +# ============================================================================= +# Zendesk-Specific Schemas +# ============================================================================= + + +class ZendeskTicketData(BaseModel): + """A Zendesk ticket.""" + + id: int = Field(..., description="Ticket ID") + subject: str = Field(..., description="Ticket subject") + description: str | None = Field(None, description="Ticket description") + status: str | None = Field( + None, description="Ticket status (new, open, pending, hold, solved, closed)" + ) + priority: str | None = Field( + None, description="Ticket priority (urgent, high, normal, low)" + ) + ticket_type: str | None = Field( + None, description="Ticket type (problem, incident, question, task)" + ) + assignee_id: int | None = Field(None, description="Assignee user ID") + requester_id: int | None = Field(None, description="Requester user ID") + tags: list[str] = Field(default_factory=list, description="Ticket tags") + url: str | None = Field(None, description="Ticket URL in agent interface") + + +class ZendeskTicketListData(BaseModel): + """A list of Zendesk tickets.""" + + tickets: list[ZendeskTicketData] = Field( + default_factory=list, description="List of tickets" + ) + total: int | None = Field(None, description="Total number of tickets") + + +class ZendeskCommentData(BaseModel): + """A Zendesk ticket comment.""" + + id: int = Field(..., description="Comment ID") + body: str = Field(..., description="Comment body text") + author_id: int | None = Field(None, description="Author user ID") + public: bool = Field(True, description="Whether the comment is public") + created_at: str | None = Field(None, description="Creation timestamp (ISO 8601)") + + +class ZendeskCommentListData(BaseModel): + """A list of Zendesk ticket comments.""" + + comments: list[ZendeskCommentData] = Field( + default_factory=list, description="List of comments" + ) + total: int | None = Field(None, description="Total number of comments") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class IssueResponse(ToolResponse[IssueData]): + """Generic issue response.""" + + pass + + +class IssueListResponse(ToolResponse[IssueListData]): + """Generic issue list response.""" + + pass + + +# --- Jira --- + + +class JiraIssueResponse(ToolResponse[JiraIssueData]): + """Response for Jira issue operations.""" + + pass + + +class JiraIssueListResponse(ToolResponse[JiraIssueListData]): + """Response for Jira issue search.""" + + pass + + +class JiraProjectListResponse(ToolResponse[JiraProjectListData]): + """Response for Jira project list.""" + + pass + + +class JiraTransitionResponse(ToolResponse[JiraTransitionData]): + """Response for Jira issue transition.""" + + pass + + +class JiraTransitionListResponse(ToolResponse[JiraTransitionListData]): + """Response for listing available Jira transitions.""" + + pass + + +class JiraCommentResponse(ToolResponse[JiraCommentData]): + """Response for Jira comment operations.""" + + pass + + +# --- Linear --- + + +class LinearIssueResponse(ToolResponse[LinearIssueData]): + """Response for Linear issue operations.""" + + pass + + +class LinearIssueListResponse(ToolResponse[LinearIssueListData]): + """Response for Linear issue list.""" + + pass + + +class LinearTeamListResponse(ToolResponse[LinearTeamListData]): + """Response for Linear team list.""" + + pass + + +class LinearCycleListResponse(ToolResponse[LinearCycleListData]): + """Response for Linear cycle list.""" + + pass + + +# --- ClickUp --- + + +class ClickUpTaskResponse(ToolResponse[ClickUpTaskData]): + """Response for ClickUp task operations.""" + + pass + + +class ClickUpTaskListResponse(ToolResponse[ClickUpTaskListData]): + """Response for ClickUp task list.""" + + pass + + +class ClickUpSpaceListResponse(ToolResponse[ClickUpSpaceListData]): + """Response for ClickUp space list.""" + + pass + + +class ClickUpCommentListResponse(ToolResponse[ClickUpCommentListData]): + """Response for ClickUp comment list.""" + + pass + + +# --- Todoist --- + + +class TodoistTaskResponse(ToolResponse[TodoistTaskData]): + """Response for Todoist task operations.""" + + pass + + +class TodoistTaskListResponse(ToolResponse[TodoistTaskListData]): + """Response for Todoist task list.""" + + pass + + +class TodoistProjectListResponse(ToolResponse[TodoistProjectListData]): + """Response for Todoist project list.""" + + pass + + +class TodoistCommentResponse(ToolResponse[TodoistCommentData]): + """Response for Todoist comment operations.""" + + pass + + +class TodoistCommentListResponse(ToolResponse[TodoistCommentListData]): + """Response for Todoist comment list.""" + + pass + + +# --- Trello --- + + +class TrelloCardResponse(ToolResponse[TrelloCardData]): + """Response for Trello card operations.""" + + pass + + +class TrelloCardListResponse(ToolResponse[TrelloCardListData]): + """Response for Trello card list.""" + + pass + + +class TrelloBoardListResponse(ToolResponse[TrelloBoardListData]): + """Response for Trello board list.""" + + pass + + +class TrelloListListResponse(ToolResponse[TrelloListListData]): + """Response for Trello list listing.""" + + pass + + +# --- Confluence --- + + +class ConfluencePageResponse(ToolResponse[ConfluencePageData]): + """Response for Confluence page operations.""" + + pass + + +class ConfluencePageListResponse(ToolResponse[ConfluencePageListData]): + """Response for Confluence page search.""" + + pass + + +class ConfluenceSpaceListResponse(ToolResponse[ConfluenceSpaceListData]): + """Response for Confluence space list.""" + + pass + + +class ConfluenceCommentResponse(ToolResponse[ConfluenceCommentData]): + """Response for Confluence comment operations.""" + + pass + + +class ConfluenceCommentListResponse(ToolResponse[ConfluenceCommentListData]): + """Response for Confluence comment list.""" + + pass + + +# --- Notion --- + + +class NotionPageResponse(ToolResponse[NotionPageData]): + """Response for Notion page operations.""" + + pass + + +class NotionPageListResponse(ToolResponse[NotionPageListData]): + """Response for Notion page search.""" + + pass + + +class NotionDatabaseQueryResponse(ToolResponse[NotionDatabaseQueryData]): + """Response for Notion database query.""" + + pass + + +class NotionBlockListResponse(ToolResponse[NotionBlockListData]): + """Response for Notion block list.""" + + pass + + +# --- Bitbucket --- + + +class BitbucketRepoResponse(ToolResponse[BitbucketRepoData]): + """Response for Bitbucket repository operations.""" + + pass + + +class BitbucketRepoListResponse(ToolResponse[BitbucketRepoListData]): + """Response for Bitbucket repository list.""" + + pass + + +class BitbucketPullRequestResponse(ToolResponse[BitbucketPullRequestData]): + """Response for Bitbucket pull request operations.""" + + pass + + +class BitbucketPullRequestListResponse(ToolResponse[BitbucketPullRequestListData]): + """Response for Bitbucket pull request list.""" + + pass + + +class BitbucketCommitListResponse(ToolResponse[BitbucketCommitListData]): + """Response for Bitbucket commit list.""" + + pass + + +# --- GitHub --- + + +class GitHubRepoResponse(ToolResponse[GitHubRepoData]): + """Response for GitHub repository operations.""" + + pass + + +class GitHubIssueResponse(ToolResponse[GitHubIssueData]): + """Response for GitHub issue operations.""" + + pass + + +class GitHubIssueListResponse(ToolResponse[GitHubIssueListData]): + """Response for GitHub issue list.""" + + pass + + +class GitHubPullRequestResponse(ToolResponse[GitHubPullRequestData]): + """Response for GitHub pull request operations.""" + + pass + + +class GitHubPullRequestListResponse(ToolResponse[GitHubPullRequestListData]): + """Response for GitHub pull request list.""" + + pass + + +class GitHubCommitListResponse(ToolResponse[GitHubCommitListData]): + """Response for GitHub commit list.""" + + pass + + +class GitHubReleaseListResponse(ToolResponse[GitHubReleaseListData]): + """Response for GitHub release list.""" + + pass + + +# --- Zendesk --- + + +class ZendeskTicketResponse(ToolResponse[ZendeskTicketData]): + """Response for Zendesk ticket operations.""" + + pass + + +class ZendeskTicketListResponse(ToolResponse[ZendeskTicketListData]): + """Response for Zendesk ticket search.""" + + pass + + +class ZendeskCommentListResponse(ToolResponse[ZendeskCommentListData]): + """Response for Zendesk comment list.""" + + pass diff --git a/src/tools/project_management/todoist.py b/src/tools/project_management/todoist.py new file mode 100644 index 0000000..0c07f1b --- /dev/null +++ b/src/tools/project_management/todoist.py @@ -0,0 +1,389 @@ +"""Todoist task management tools. + +Uses the Todoist REST API via the todoist-api-python SDK. Requires a +TODOIST_API_KEY environment variable. + +API Reference: https://developer.todoist.com/rest/v2/ +""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + TodoistCommentData, + TodoistCommentListData, + TodoistCommentListResponse, + TodoistProjectData, + TodoistProjectListData, + TodoistProjectListResponse, + TodoistTaskData, + TodoistTaskListData, + TodoistTaskListResponse, + TodoistTaskResponse, +) + +try: + from todoist_api_python.api import TodoistAPI +except ImportError as err: + raise ImportError( + "todoist-api-python is required for Todoist tools. " + "Install with: pip install todoist-api-python" + ) from err + +logger = logging.getLogger("humcp.tools.todoist") + + +async def _get_todoist_client() -> tuple[TodoistAPI | None, str | None]: + """Create a Todoist client from environment variables. + + Returns: + A tuple of (client, error_message). + """ + api_key = await resolve_credential("TODOIST_API_KEY") + if not api_key: + return ( + None, + "Todoist API key not configured. Set TODOIST_API_KEY environment variable.", + ) + return TodoistAPI(api_key), None + + +def _task_to_data(task: object) -> TodoistTaskData: + """Convert a Todoist task object to a TodoistTaskData model. + + Args: + task: A Todoist task object from the SDK. + + Returns: + A TodoistTaskData Pydantic model. + """ + due_str = None + if hasattr(task, "due") and task.due: + due_str = getattr(task.due, "date", None) or getattr(task.due, "string", None) + + labels: list[str] = [] + if hasattr(task, "labels") and task.labels: + labels = list(task.labels) + + return TodoistTaskData( + id=task.id, # type: ignore[attr-defined] + content=task.content, # type: ignore[attr-defined] + description=getattr(task, "description", None), + project_id=getattr(task, "project_id", None), + section_id=getattr(task, "section_id", None), + priority=getattr(task, "priority", None), + url=getattr(task, "url", None), + due=due_str, + is_completed=getattr(task, "is_completed", False), + labels=labels, + ) + + +@tool() +async def todoist_create_task( + content: str, + description: str | None = None, + project_id: str | None = None, + section_id: str | None = None, + priority: int | None = None, + due_string: str | None = None, + due_date: str | None = None, + labels: list[str] | None = None, +) -> TodoistTaskResponse: + """Create a new task in Todoist. + + Args: + content: The task content/title. + description: Optional task description. + project_id: Optional project ID to add the task to. + section_id: Optional section ID within the project. + priority: Optional priority level (1=normal, 2=medium, 3=high, 4=urgent). + due_string: Optional due date in natural language (e.g., "tomorrow at 12:00"). + due_date: Optional due date in YYYY-MM-DD format. + labels: Optional list of label names to apply. + + Returns: + Details of the newly created task. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistTaskResponse(success=False, error=error) + + kwargs: dict = {"content": content} + if description is not None: + kwargs["description"] = description + if project_id is not None: + kwargs["project_id"] = project_id + if section_id is not None: + kwargs["section_id"] = section_id + if priority is not None: + kwargs["priority"] = priority + if due_string is not None: + kwargs["due_string"] = due_string + if due_date is not None: + kwargs["due_date"] = due_date + if labels is not None: + kwargs["labels"] = labels + + task = client.add_task(**kwargs) + logger.info("Created Todoist task %s", task.id) + + return TodoistTaskResponse(success=True, data=_task_to_data(task)) + except Exception as e: + logger.exception("Failed to create Todoist task") + return TodoistTaskResponse(success=False, error=f"Failed to create task: {e}") + + +@tool() +async def todoist_get_task(task_id: str) -> TodoistTaskResponse: + """Retrieve a specific Todoist task by its ID. + + Args: + task_id: The Todoist task ID. + + Returns: + Task details including content, description, due date, and priority. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistTaskResponse(success=False, error=error) + + task = client.get_task(task_id) + + return TodoistTaskResponse(success=True, data=_task_to_data(task)) + except Exception as e: + logger.exception("Failed to get Todoist task %s", task_id) + return TodoistTaskResponse(success=False, error=f"Failed to get task: {e}") + + +@tool() +async def todoist_update_task( + task_id: str, + content: str | None = None, + description: str | None = None, + priority: int | None = None, + due_string: str | None = None, + due_date: str | None = None, + labels: list[str] | None = None, +) -> TodoistTaskResponse: + """Update an existing Todoist task. + + Args: + task_id: The ID of the task to update. + content: New task content/title. + description: New task description. + priority: New priority level (1=normal, 2=medium, 3=high, 4=urgent). + due_string: New due date in natural language. + due_date: New due date in YYYY-MM-DD format. + labels: New list of label names (replaces existing labels). + + Returns: + Updated task details. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistTaskResponse(success=False, error=error) + + kwargs: dict = {} + if content is not None: + kwargs["content"] = content + if description is not None: + kwargs["description"] = description + if priority is not None: + kwargs["priority"] = priority + if due_string is not None: + kwargs["due_string"] = due_string + if due_date is not None: + kwargs["due_date"] = due_date + if labels is not None: + kwargs["labels"] = labels + + if not kwargs: + return TodoistTaskResponse( + success=False, error="At least one field must be provided to update." + ) + + result = client.update_task(task_id, **kwargs) + + logger.info("Updated Todoist task %s", task_id) + + # update_task may return bool or task; fetch task for consistent response + if isinstance(result, bool): + task = client.get_task(task_id) + return TodoistTaskResponse(success=True, data=_task_to_data(task)) + + return TodoistTaskResponse(success=True, data=_task_to_data(result)) + except Exception as e: + logger.exception("Failed to update Todoist task %s", task_id) + return TodoistTaskResponse(success=False, error=f"Failed to update task: {e}") + + +@tool() +async def todoist_close_task(task_id: str) -> TodoistTaskResponse: + """Close (complete) a Todoist task. + + Args: + task_id: The ID of the task to close. + + Returns: + The closed task details. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistTaskResponse(success=False, error=error) + + client.close_task(task_id) # type: ignore[attr-defined] + + logger.info("Closed Todoist task %s", task_id) + + # Fetch updated task to return full details + task = client.get_task(task_id) + return TodoistTaskResponse(success=True, data=_task_to_data(task)) + except Exception as e: + logger.exception("Failed to close Todoist task %s", task_id) + return TodoistTaskResponse(success=False, error=f"Failed to close task: {e}") + + +@tool() +async def todoist_get_tasks( + project_id: str | None = None, + section_id: str | None = None, + label: str | None = None, +) -> TodoistTaskListResponse: + """Get active tasks from Todoist, optionally filtered by project, section, or label. + + Args: + project_id: Optional project ID to filter tasks by. + section_id: Optional section ID to filter tasks by. + label: Optional label name to filter tasks by. + + Returns: + List of active tasks matching the filters. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistTaskListResponse(success=False, error=error) + + kwargs: dict = {} + if project_id is not None: + kwargs["project_id"] = project_id + if section_id is not None: + kwargs["section_id"] = section_id + if label is not None: + kwargs["label"] = label + + tasks_result = client.get_tasks(**kwargs) + + # The SDK may return a tuple or list depending on version + if isinstance(tasks_result, tuple): + tasks_iter = tasks_result[0] + else: + tasks_iter = tasks_result + + tasks = [_task_to_data(t) for t in tasks_iter] + + logger.info("Retrieved %d Todoist tasks", len(tasks)) + + return TodoistTaskListResponse( + success=True, + data=TodoistTaskListData(tasks=tasks, total=len(tasks)), + ) + except Exception as e: + logger.exception("Failed to get Todoist tasks") + return TodoistTaskListResponse(success=False, error=f"Failed to get tasks: {e}") + + +@tool() +async def todoist_list_projects() -> TodoistProjectListResponse: + """List all projects in the Todoist account. + + Returns: + List of projects with their IDs and names. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistProjectListResponse(success=False, error=error) + + projects_result = client.get_projects() + + if isinstance(projects_result, tuple): + projects_iter = projects_result[0] + else: + projects_iter = projects_result + + projects = [ + TodoistProjectData( + id=p.id, + name=p.name, + color=getattr(p, "color", None), + is_favorite=getattr(p, "is_favorite", False), + url=getattr(p, "url", None), + ) + for p in projects_iter + ] + + logger.info("Listed %d Todoist projects", len(projects)) + + return TodoistProjectListResponse( + success=True, + data=TodoistProjectListData(projects=projects, total=len(projects)), + ) + except Exception as e: + logger.exception("Failed to list Todoist projects") + return TodoistProjectListResponse( + success=False, error=f"Failed to list projects: {e}" + ) + + +@tool() +async def todoist_get_comments(task_id: str) -> TodoistCommentListResponse: + """Get all comments on a Todoist task. + + Args: + task_id: The ID of the task to get comments for. + + Returns: + List of comments on the task. + """ + try: + client, error = await _get_todoist_client() + if error or client is None: + return TodoistCommentListResponse(success=False, error=error) + + comments_result = client.get_comments(task_id=task_id) + + if isinstance(comments_result, tuple): + comments_iter = comments_result[0] + else: + comments_iter = comments_result + + comments = [ + TodoistCommentData( + id=c.id, + content=c.content, + task_id=getattr(c, "task_id", None), + posted_at=getattr(c, "posted_at", None), + ) + for c in comments_iter + ] + + logger.info("Retrieved %d comments for Todoist task %s", len(comments), task_id) + + return TodoistCommentListResponse( + success=True, + data=TodoistCommentListData(comments=comments, total=len(comments)), + ) + except Exception as e: + logger.exception("Failed to get comments for Todoist task %s", task_id) + return TodoistCommentListResponse( + success=False, error=f"Failed to get comments: {e}" + ) diff --git a/src/tools/project_management/trello.py b/src/tools/project_management/trello.py new file mode 100644 index 0000000..3c72851 --- /dev/null +++ b/src/tools/project_management/trello.py @@ -0,0 +1,358 @@ +"""Trello project management tools for board, list, and card management. + +Uses the Trello REST API v1. Requires TRELLO_API_KEY and TRELLO_TOKEN +environment variables. + +API Reference: https://developer.atlassian.com/cloud/trello/rest/ +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + TrelloBoardData, + TrelloBoardListData, + TrelloBoardListResponse, + TrelloCardData, + TrelloCardListData, + TrelloCardListResponse, + TrelloCardResponse, + TrelloListData, + TrelloListListData, + TrelloListListResponse, +) + +logger = logging.getLogger("humcp.tools.trello") + +TRELLO_BASE_URL = "https://api.trello.com/1" + + +async def _get_auth_params() -> tuple[dict[str, str] | None, str | None]: + """Build Trello authentication query parameters. + + Returns: + A tuple of (params_dict, error_message). + """ + api_key = await resolve_credential("TRELLO_API_KEY") + token = await resolve_credential("TRELLO_TOKEN") + + if not api_key: + return ( + None, + "Trello API key not configured. Set TRELLO_API_KEY environment variable.", + ) + if not token: + return ( + None, + "Trello token not configured. Set TRELLO_TOKEN environment variable.", + ) + + return {"key": api_key, "token": token}, None + + +def _parse_card(card: dict) -> TrelloCardData: + """Parse a Trello card API response into TrelloCardData. + + Args: + card: Raw card dict from the Trello API. + + Returns: + Parsed TrelloCardData. + """ + label_names = [ + label.get("name", label.get("color", "")) + for label in card.get("labels", []) + if label.get("name") or label.get("color") + ] + + return TrelloCardData( + id=card["id"], + name=card["name"], + description=card.get("desc"), + url=card.get("url"), + list_name=card.get("list", {}).get("name") if card.get("list") else None, + labels=label_names, + due=card.get("due"), + closed=card.get("closed", False), + ) + + +@tool() +async def trello_create_card( + list_id: str, + name: str, + description: str = "", + due: str | None = None, + label_ids: str | None = None, + member_ids: str | None = None, +) -> TrelloCardResponse: + """Create a new card in a Trello list. + + Args: + list_id: The ID of the Trello list to add the card to. + name: The name/title of the card. + description: The description of the card (Markdown supported). + due: Optional due date (ISO 8601 format, e.g., "2025-12-31T12:00:00.000Z"). + label_ids: Optional comma-separated label IDs to apply. + member_ids: Optional comma-separated member IDs to assign. + + Returns: + Details of the newly created card. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloCardResponse(success=False, error=error) + + params: dict = { + **auth_params, + "idList": list_id, + "name": name, + "desc": description, + } + if due: + params["due"] = due + if label_ids: + params["idLabels"] = label_ids + if member_ids: + params["idMembers"] = member_ids + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TRELLO_BASE_URL}/cards", + params=params, + ) + response.raise_for_status() + card = response.json() + + data = _parse_card(card) + + logger.info("Created Trello card %s in list %s", card["id"], list_id) + return TrelloCardResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Trello card in list %s", list_id) + return TrelloCardResponse(success=False, error=f"Failed to create card: {e}") + + +@tool() +async def trello_get_card(card_id: str) -> TrelloCardResponse: + """Get details of a specific Trello card. + + Args: + card_id: The ID of the Trello card. + + Returns: + Card details including name, description, labels, due date, and URL. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloCardResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{TRELLO_BASE_URL}/cards/{card_id}", + params=auth_params, + ) + response.raise_for_status() + card = response.json() + + data = _parse_card(card) + + return TrelloCardResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Trello card %s", card_id) + return TrelloCardResponse(success=False, error=f"Failed to get card: {e}") + + +@tool() +async def trello_update_card( + card_id: str, + name: str | None = None, + description: str | None = None, + due: str | None = None, + closed: bool | None = None, + list_id: str | None = None, +) -> TrelloCardResponse: + """Update an existing Trello card. + + Args: + card_id: The ID of the card to update. + name: New card name. + description: New card description. + due: New due date (ISO 8601 format) or empty string to remove. + closed: Set to True to archive the card, False to unarchive. + list_id: New list ID to move the card to. + + Returns: + Updated card details. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloCardResponse(success=False, error=error) + + params: dict = {**auth_params} + if name is not None: + params["name"] = name + if description is not None: + params["desc"] = description + if due is not None: + params["due"] = due + if closed is not None: + params["closed"] = str(closed).lower() + if list_id is not None: + params["idList"] = list_id + + if len(params) == len(auth_params): + return TrelloCardResponse( + success=False, error="At least one field must be provided to update." + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{TRELLO_BASE_URL}/cards/{card_id}", + params=params, + ) + response.raise_for_status() + card = response.json() + + data = _parse_card(card) + + logger.info("Updated Trello card %s", card_id) + return TrelloCardResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Trello card %s", card_id) + return TrelloCardResponse(success=False, error=f"Failed to update card: {e}") + + +@tool() +async def trello_get_board_cards( + board_id: str, +) -> TrelloCardListResponse: + """Get all cards on a Trello board. + + Args: + board_id: The ID of the Trello board. + + Returns: + List of all cards on the board. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloCardListResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{TRELLO_BASE_URL}/boards/{board_id}/cards", + params=auth_params, + ) + response.raise_for_status() + cards_json = response.json() + + cards = [_parse_card(card) for card in cards_json] + + logger.info("Retrieved %d cards from Trello board %s", len(cards), board_id) + + return TrelloCardListResponse( + success=True, + data=TrelloCardListData(cards=cards, total=len(cards)), + ) + except Exception as e: + logger.exception("Failed to get cards for Trello board %s", board_id) + return TrelloCardListResponse(success=False, error=f"Failed to get cards: {e}") + + +@tool() +async def trello_get_boards() -> TrelloBoardListResponse: + """Get all boards accessible to the authenticated Trello user. + + Returns: + List of Trello boards with their details. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloBoardListResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{TRELLO_BASE_URL}/members/me/boards", + params=auth_params, + ) + response.raise_for_status() + boards_json = response.json() + + boards = [ + TrelloBoardData( + id=board["id"], + name=board["name"], + description=board.get("desc"), + url=board.get("url"), + closed=board.get("closed", False), + ) + for board in boards_json + ] + + logger.info("Retrieved %d Trello boards", len(boards)) + + return TrelloBoardListResponse( + success=True, + data=TrelloBoardListData(boards=boards, total=len(boards)), + ) + except Exception as e: + logger.exception("Failed to get Trello boards") + return TrelloBoardListResponse( + success=False, error=f"Failed to get boards: {e}" + ) + + +@tool() +async def trello_get_board_lists( + board_id: str, +) -> TrelloListListResponse: + """Get all lists on a Trello board. + + Args: + board_id: The ID of the Trello board. + + Returns: + List of lists (columns) on the board. + """ + try: + auth_params, error = await _get_auth_params() + if error or auth_params is None: + return TrelloListListResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{TRELLO_BASE_URL}/boards/{board_id}/lists", + params=auth_params, + ) + response.raise_for_status() + lists_json = response.json() + + lists = [ + TrelloListData( + id=lst["id"], + name=lst["name"], + closed=lst.get("closed", False), + ) + for lst in lists_json + ] + + logger.info("Retrieved %d lists from Trello board %s", len(lists), board_id) + + return TrelloListListResponse( + success=True, + data=TrelloListListData(lists=lists, total=len(lists)), + ) + except Exception as e: + logger.exception("Failed to get lists for Trello board %s", board_id) + return TrelloListListResponse(success=False, error=f"Failed to get lists: {e}") diff --git a/src/tools/project_management/zendesk.py b/src/tools/project_management/zendesk.py new file mode 100644 index 0000000..d965e50 --- /dev/null +++ b/src/tools/project_management/zendesk.py @@ -0,0 +1,422 @@ +"""Zendesk tools for ticket management, search, and comments. + +Uses the Zendesk Support API v2. Requires ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, +and ZENDESK_API_TOKEN environment variables. + +API Reference: https://developer.zendesk.com/api-reference/ticketing/ +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.project_management.schemas import ( + ZendeskCommentData, + ZendeskCommentListData, + ZendeskCommentListResponse, + ZendeskTicketData, + ZendeskTicketListData, + ZendeskTicketListResponse, + ZendeskTicketResponse, +) + +logger = logging.getLogger("humcp.tools.zendesk") + + +async def _get_zendesk_config() -> tuple[ + str | None, str | None, tuple[str, str] | None, str | None +]: + """Build Zendesk API configuration from environment variables. + + Returns: + A tuple of (base_url, subdomain, auth_tuple, error_message). + """ + subdomain = await resolve_credential("ZENDESK_SUBDOMAIN") + email = await resolve_credential("ZENDESK_EMAIL") + api_token = await resolve_credential("ZENDESK_API_TOKEN") + + if not subdomain: + return ( + None, + None, + None, + "Zendesk subdomain not configured. Set ZENDESK_SUBDOMAIN environment variable.", + ) + if not email: + return ( + None, + None, + None, + "Zendesk email not configured. Set ZENDESK_EMAIL environment variable.", + ) + if not api_token: + return ( + None, + None, + None, + "Zendesk API token not configured. Set ZENDESK_API_TOKEN environment variable.", + ) + + base_url = f"https://{subdomain}.zendesk.com/api/v2" + # Zendesk uses email/token authentication + auth = (f"{email}/token", api_token) + + return base_url, subdomain, auth, None + + +def _parse_ticket(ticket: dict, subdomain: str = "") -> ZendeskTicketData: + """Parse a Zendesk ticket API response into ZendeskTicketData. + + Args: + ticket: Raw ticket dict from the Zendesk API. + subdomain: The Zendesk subdomain for constructing ticket URLs. + + Returns: + Parsed ZendeskTicketData. + """ + return ZendeskTicketData( + id=ticket["id"], + subject=ticket.get("subject", ""), + description=ticket.get("description"), + status=ticket.get("status"), + priority=ticket.get("priority"), + ticket_type=ticket.get("type"), + assignee_id=ticket.get("assignee_id"), + requester_id=ticket.get("requester_id"), + tags=ticket.get("tags", []), + url=f"https://{subdomain}.zendesk.com/agent/tickets/{ticket['id']}", + ) + + +@tool() +async def zendesk_create_ticket( + subject: str, + description: str, + priority: str = "normal", + ticket_type: str | None = None, + tags: list[str] | None = None, + assignee_id: int | None = None, + requester_id: int | None = None, +) -> ZendeskTicketResponse: + """Create a new ticket in Zendesk. + + Args: + subject: The ticket subject line. + description: The ticket description/body (first comment). + priority: Ticket priority: "urgent", "high", "normal", or "low". + ticket_type: Optional ticket type: "problem", "incident", "question", or "task". + tags: Optional list of tags to apply to the ticket. + assignee_id: Optional user ID to assign the ticket to. + requester_id: Optional requester user ID. + + Returns: + Details of the newly created ticket. + """ + try: + base_url, subdomain, auth, error = await _get_zendesk_config() + if error or base_url is None or auth is None: + return ZendeskTicketResponse(success=False, error=error) + + valid_priorities = ("urgent", "high", "normal", "low") + if priority not in valid_priorities: + return ZendeskTicketResponse( + success=False, + error=f"priority must be one of: {', '.join(valid_priorities)}", + ) + + ticket_data: dict = { + "subject": subject, + "comment": {"body": description}, + "priority": priority, + } + if ticket_type: + valid_types = ("problem", "incident", "question", "task") + if ticket_type not in valid_types: + return ZendeskTicketResponse( + success=False, + error=f"ticket_type must be one of: {', '.join(valid_types)}", + ) + ticket_data["type"] = ticket_type + if tags: + ticket_data["tags"] = tags + if assignee_id is not None: + ticket_data["assignee_id"] = assignee_id + if requester_id is not None: + ticket_data["requester_id"] = requester_id + + payload = {"ticket": ticket_data} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{base_url}/tickets.json", + json=payload, + auth=auth, + ) + response.raise_for_status() + result = response.json() + + ticket = result.get("ticket", {}) + + logger.info("Created Zendesk ticket #%s", ticket.get("id")) + + data = _parse_ticket(ticket, subdomain or "") + + return ZendeskTicketResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to create Zendesk ticket") + return ZendeskTicketResponse( + success=False, error=f"Failed to create ticket: {e}" + ) + + +@tool() +async def zendesk_get_ticket(ticket_id: int) -> ZendeskTicketResponse: + """Retrieve a Zendesk ticket by its ID. + + Args: + ticket_id: The numeric ID of the Zendesk ticket. + + Returns: + Ticket details including subject, description, status, priority, type, and tags. + """ + try: + base_url, subdomain, auth, error = await _get_zendesk_config() + if error or base_url is None or auth is None: + return ZendeskTicketResponse(success=False, error=error) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{base_url}/tickets/{ticket_id}.json", + auth=auth, + ) + response.raise_for_status() + result = response.json() + + ticket = result.get("ticket", {}) + data = _parse_ticket(ticket, subdomain or "") + + return ZendeskTicketResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to get Zendesk ticket %d", ticket_id) + return ZendeskTicketResponse(success=False, error=f"Failed to get ticket: {e}") + + +@tool() +async def zendesk_update_ticket( + ticket_id: int, + status: str | None = None, + priority: str | None = None, + assignee_id: int | None = None, + tags: list[str] | None = None, + subject: str | None = None, + ticket_type: str | None = None, + comment: str | None = None, + comment_public: bool = True, +) -> ZendeskTicketResponse: + """Update an existing Zendesk ticket. Optionally add a comment. + + Args: + ticket_id: The numeric ID of the ticket to update. + status: New status: "new", "open", "pending", "hold", "solved", or "closed". + priority: New priority: "urgent", "high", "normal", or "low". + assignee_id: New assignee user ID. + tags: New list of tags (replaces existing tags). + subject: New ticket subject. + ticket_type: New ticket type: "problem", "incident", "question", or "task". + comment: Optional comment body to add to the ticket. + comment_public: Whether the comment is public (True) or internal (False). + + Returns: + Updated ticket details. + """ + try: + base_url, subdomain, auth, error = await _get_zendesk_config() + if error or base_url is None or auth is None: + return ZendeskTicketResponse(success=False, error=error) + + ticket_data: dict = {} + if status is not None: + valid_statuses = ("new", "open", "pending", "hold", "solved", "closed") + if status not in valid_statuses: + return ZendeskTicketResponse( + success=False, + error=f"status must be one of: {', '.join(valid_statuses)}", + ) + ticket_data["status"] = status + if priority is not None: + valid_priorities = ("urgent", "high", "normal", "low") + if priority not in valid_priorities: + return ZendeskTicketResponse( + success=False, + error=f"priority must be one of: {', '.join(valid_priorities)}", + ) + ticket_data["priority"] = priority + if assignee_id is not None: + ticket_data["assignee_id"] = assignee_id + if tags is not None: + ticket_data["tags"] = tags + if subject is not None: + ticket_data["subject"] = subject + if ticket_type is not None: + ticket_data["type"] = ticket_type + if comment is not None: + ticket_data["comment"] = {"body": comment, "public": comment_public} + + if not ticket_data: + return ZendeskTicketResponse( + success=False, error="At least one field must be provided to update." + ) + + payload = {"ticket": ticket_data} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{base_url}/tickets/{ticket_id}.json", + json=payload, + auth=auth, + ) + response.raise_for_status() + result = response.json() + + ticket = result.get("ticket", {}) + + logger.info("Updated Zendesk ticket #%d", ticket_id) + + data = _parse_ticket(ticket, subdomain or "") + + return ZendeskTicketResponse(success=True, data=data) + except Exception as e: + logger.exception("Failed to update Zendesk ticket %d", ticket_id) + return ZendeskTicketResponse( + success=False, error=f"Failed to update ticket: {e}" + ) + + +@tool() +async def zendesk_search_tickets( + query: str, + sort_by: str = "created_at", + sort_order: str = "desc", + per_page: int = 25, +) -> ZendeskTicketListResponse: + """Search for tickets in Zendesk using the Zendesk search syntax. + + Args: + query: The search query string (Zendesk search syntax, e.g., "status:open priority:high"). + sort_by: Sort field: "created_at", "updated_at", "priority", "status", or "ticket_type". + sort_order: Sort direction: "asc" or "desc". + per_page: Maximum number of results to return (max 100). + + Returns: + List of tickets matching the search query. + """ + try: + base_url, subdomain, auth, error = await _get_zendesk_config() + if error or base_url is None or auth is None: + return ZendeskTicketListResponse(success=False, error=error) + + if per_page < 1: + return ZendeskTicketListResponse( + success=False, error="per_page must be at least 1" + ) + + params = { + "query": f"type:ticket {query}", + "sort_by": sort_by, + "sort_order": sort_order, + "per_page": min(per_page, 100), + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{base_url}/search.json", + params=params, # type: ignore[arg-type] + auth=auth, + ) + response.raise_for_status() + result = response.json() + + tickets = [ + _parse_ticket(ticket, subdomain or "") + for ticket in result.get("results", []) + ] + + logger.info("Zendesk search returned %d tickets for: %s", len(tickets), query) + + return ZendeskTicketListResponse( + success=True, + data=ZendeskTicketListData( + tickets=tickets, + total=result.get("count", len(tickets)), + ), + ) + except Exception as e: + logger.exception("Failed to search Zendesk tickets for: %s", query) + return ZendeskTicketListResponse( + success=False, error=f"Failed to search tickets: {e}" + ) + + +@tool() +async def zendesk_get_ticket_comments( + ticket_id: int, + per_page: int = 25, +) -> ZendeskCommentListResponse: + """Get all comments on a Zendesk ticket. + + Args: + ticket_id: The numeric ID of the ticket. + per_page: Maximum number of comments to return (max 100). + + Returns: + List of comments on the ticket, ordered chronologically. + """ + try: + base_url, _subdomain, auth, error = await _get_zendesk_config() + if error or base_url is None or auth is None: + return ZendeskCommentListResponse(success=False, error=error) + + if per_page < 1: + return ZendeskCommentListResponse( + success=False, error="per_page must be at least 1" + ) + + params = {"per_page": min(per_page, 100)} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{base_url}/tickets/{ticket_id}/comments.json", + params=params, + auth=auth, + ) + response.raise_for_status() + result = response.json() + + comments = [ + ZendeskCommentData( + id=comment["id"], + body=comment.get("body", ""), + author_id=comment.get("author_id"), + public=comment.get("public", True), + created_at=comment.get("created_at"), + ) + for comment in result.get("comments", []) + ] + + logger.info( + "Retrieved %d comments for Zendesk ticket #%d", len(comments), ticket_id + ) + + return ZendeskCommentListResponse( + success=True, + data=ZendeskCommentListData(comments=comments, total=len(comments)), + ) + except Exception as e: + logger.exception("Failed to get comments for Zendesk ticket %d", ticket_id) + return ZendeskCommentListResponse( + success=False, error=f"Failed to get comments: {e}" + ) diff --git a/src/tools/research/SKILL.md b/src/tools/research/SKILL.md new file mode 100644 index 0000000..5ac497c --- /dev/null +++ b/src/tools/research/SKILL.md @@ -0,0 +1,101 @@ +--- +name: academic-research +description: Search and retrieve academic papers and knowledge articles from arXiv, PubMed, and Wikipedia. Use when the user needs scientific papers, biomedical research, or encyclopedic knowledge. +--- + +# Research Tools + +Tools for searching academic databases and knowledge sources. + +## arXiv Tools + +Search and read academic papers from arXiv. + +### Requirements + +No API key required. Install packages: +- `arxiv` +- `pypdf` (for reading paper PDFs) + +### Search Papers + +```python +result = await arxiv_search( + query="transformer attention mechanism", + max_results=5 +) +``` + +### Read Paper Content + +```python +result = await arxiv_read_paper( + paper_ids=["2103.03404v1", "2301.07041v2"], + pages_to_read=5 +) +``` + +## PubMed Tools + +Search biomedical and life sciences literature. + +### Requirements + +No API key required (optional: `NCBI_API_KEY` for higher rate limits). + +### Search Articles + +```python +result = await pubmed_search( + query="CRISPR gene therapy", + max_results=10 +) +``` + +### Get Article by PMID + +```python +result = await pubmed_get_article(pmid="12345678") +``` + +## Wikipedia Tools + +Search and retrieve Wikipedia articles. + +### Requirements + +No API key required. Install package: `wikipedia` + +### Search Wikipedia + +```python +result = await wikipedia_search( + query="quantum computing", + max_results=5 +) +``` + +### Get Full Page + +```python +result = await wikipedia_get_page(title="Quantum computing") +``` + +### Response Format + +All tools return: + +```json +{ + "success": true, + "data": { ... } +} +``` + +## When to Use + +- Searching for academic or scientific papers +- Looking up biomedical research and clinical studies +- Finding encyclopedic knowledge on any topic +- Getting abstracts, summaries, and full-text content +- Citing sources with DOIs and URLs diff --git a/src/tools/research/__init__.py b/src/tools/research/__init__.py new file mode 100644 index 0000000..3ae37c0 --- /dev/null +++ b/src/tools/research/__init__.py @@ -0,0 +1 @@ +# Academic and knowledge research tools diff --git a/src/tools/research/arxiv.py b/src/tools/research/arxiv.py new file mode 100644 index 0000000..d42b919 --- /dev/null +++ b/src/tools/research/arxiv.py @@ -0,0 +1,267 @@ +"""arXiv search and paper reading tools.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from tempfile import mkdtemp + +from src.humcp.decorator import tool +from src.tools.research.schemas import ( + ArxivArticle, + ArxivGetPaperData, + ArxivGetPaperResponse, + ArxivPageContent, + ArxivPaperContent, + ArxivReadPaperData, + ArxivReadPaperResponse, + ArxivSearchData, + ArxivSearchResponse, +) + +try: + import arxiv +except ImportError as err: + raise ImportError( + "arxiv is required for arXiv tools. Install with: pip install arxiv" + ) from err + +try: + from pypdf import PdfReader +except ImportError as err: + raise ImportError( + "pypdf is required for arXiv paper reading. Install with: pip install pypdf" + ) from err + +logger = logging.getLogger("humcp.tools.arxiv") + + +_SORT_BY_MAP = { + "relevance": arxiv.SortCriterion.Relevance, + "lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate, + "submittedDate": arxiv.SortCriterion.SubmittedDate, +} + +_SORT_ORDER_MAP = { + "descending": arxiv.SortOrder.Descending, + "ascending": arxiv.SortOrder.Ascending, +} + + +@tool() +async def arxiv_search( + query: str, + max_results: int = 10, + sort_by: str = "relevance", + sort_order: str = "descending", +) -> ArxivSearchResponse: + """Search arXiv for academic papers matching a query. + + Supports arXiv query syntax: prefix fields with ti: (title), au: (author), + abs: (abstract), cat: (category), e.g. "ti:transformer AND cat:cs.CL". + + Args: + query: The search query to find papers on arXiv. Supports arXiv query syntax. + max_results: Maximum number of results to return (default 10, max 100). + sort_by: Sort criterion. One of "relevance", "lastUpdatedDate", "submittedDate". Default is "relevance". + sort_order: Sort direction. One of "descending", "ascending". Default is "descending". + + Returns: + Search results with article metadata including title, authors, and abstract. + """ + try: + if max_results < 1: + return ArxivSearchResponse( + success=False, error="max_results must be at least 1" + ) + + max_results = min(max_results, 100) + + criterion = _SORT_BY_MAP.get(sort_by) + if criterion is None: + return ArxivSearchResponse( + success=False, + error=f"Invalid sort_by '{sort_by}'. Must be one of: {', '.join(_SORT_BY_MAP.keys())}", + ) + + order = _SORT_ORDER_MAP.get(sort_order) + if order is None: + return ArxivSearchResponse( + success=False, + error=f"Invalid sort_order '{sort_order}'. Must be one of: {', '.join(_SORT_ORDER_MAP.keys())}", + ) + + logger.info( + "arXiv search query_length=%d max_results=%d sort_by=%s sort_order=%s", + len(query), + max_results, + sort_by, + sort_order, + ) + + client = arxiv.Client() + search = arxiv.Search( + query=query, + max_results=max_results, + sort_by=criterion, + sort_order=order, + ) + + articles: list[ArxivArticle] = [] + for result in client.results(search): + try: + article = ArxivArticle( + title=result.title, + article_id=result.get_short_id(), + entry_id=result.entry_id, + authors=[author.name for author in result.authors], + primary_category=result.primary_category, + categories=list(result.categories), + published=result.published.isoformat() + if result.published + else None, + pdf_url=result.pdf_url, + summary=result.summary, + comment=result.comment, + ) + articles.append(article) + except Exception as e: + logger.warning("Skipping article due to parse error: %s", e) + + logger.info("arXiv search complete results=%d", len(articles)) + + return ArxivSearchResponse( + success=True, + data=ArxivSearchData(query=query, results=articles), + ) + except Exception as e: + logger.exception("arXiv search failed") + return ArxivSearchResponse( + success=False, error=f"arXiv search failed: {str(e)}" + ) + + +@tool() +async def arxiv_read_paper( + paper_ids: list[str], + pages_to_read: int | None = None, +) -> ArxivReadPaperResponse: + """Download and read arXiv papers by their IDs. + + Args: + paper_ids: List of arXiv paper IDs (e.g. ['2103.03404v1', '2301.07041v2']). + pages_to_read: Maximum number of pages to read per paper. None reads all pages. + + Returns: + Extracted text content from the requested papers. + """ + try: + if not paper_ids: + return ArxivReadPaperResponse( + success=False, error="paper_ids must not be empty" + ) + + logger.info("arXiv read papers=%s pages_limit=%s", paper_ids, pages_to_read) + + download_dir = Path(mkdtemp(prefix="arxiv_pdfs_")) + client = arxiv.Client() + + papers: list[ArxivPaperContent] = [] + for result in client.results(search=arxiv.Search(id_list=paper_ids)): + try: + pages_content: list[ArxivPageContent] = [] + + if result.pdf_url: + logger.info("Downloading PDF: %s", result.pdf_url) + pdf_path = result.download_pdf(dirpath=str(download_dir)) + pdf_reader = PdfReader(pdf_path) + + for page_number, page in enumerate(pdf_reader.pages, start=1): + if pages_to_read is not None and page_number > pages_to_read: + break + text = page.extract_text() or "" + pages_content.append( + ArxivPageContent(page=page_number, text=text) + ) + + paper = ArxivPaperContent( + title=result.title, + article_id=result.get_short_id(), + authors=[author.name for author in result.authors], + summary=result.summary, + pages=pages_content, + ) + papers.append(paper) + except Exception as e: + logger.warning( + "Skipping paper %s due to error: %s", result.get_short_id(), e + ) + + logger.info("arXiv read complete papers=%d", len(papers)) + + return ArxivReadPaperResponse( + success=True, + data=ArxivReadPaperData(papers=papers), + ) + except Exception as e: + logger.exception("arXiv read papers failed") + return ArxivReadPaperResponse( + success=False, error=f"arXiv read papers failed: {str(e)}" + ) + + +@tool() +async def arxiv_get_paper( + paper_id: str, +) -> ArxivGetPaperResponse: + """Get metadata for a single arXiv paper by its ID. + + Returns the paper's title, authors, abstract, categories, and PDF URL + without downloading the full PDF. + + Args: + paper_id: The arXiv paper ID (e.g. "2103.03404" or "2103.03404v1"). + + Returns: + Paper metadata or an error message. + """ + try: + if not paper_id.strip(): + return ArxivGetPaperResponse( + success=False, error="paper_id must not be empty" + ) + + logger.info("arXiv get paper id=%s", paper_id) + + client = arxiv.Client() + results = list(client.results(arxiv.Search(id_list=[paper_id.strip()]))) + + if not results: + return ArxivGetPaperResponse( + success=False, error=f"Paper '{paper_id}' not found on arXiv." + ) + + result = results[0] + article = ArxivArticle( + title=result.title, + article_id=result.get_short_id(), + entry_id=result.entry_id, + authors=[author.name for author in result.authors], + primary_category=result.primary_category, + categories=list(result.categories), + published=result.published.isoformat() if result.published else None, + pdf_url=result.pdf_url, + summary=result.summary, + comment=result.comment, + ) + + logger.info("arXiv get paper complete title=%s", article.title) + return ArxivGetPaperResponse( + success=True, + data=ArxivGetPaperData(article=article), + ) + except Exception as e: + logger.exception("arXiv get paper failed") + return ArxivGetPaperResponse( + success=False, error=f"arXiv get paper failed: {str(e)}" + ) diff --git a/src/tools/research/pubmed.py b/src/tools/research/pubmed.py new file mode 100644 index 0000000..7060724 --- /dev/null +++ b/src/tools/research/pubmed.py @@ -0,0 +1,364 @@ +"""PubMed search and article retrieval tools.""" + +from __future__ import annotations + +import logging +from xml.etree import ElementTree + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.research.schemas import ( + PubMedArticle, + PubMedGetArticleData, + PubMedGetArticleResponse, + PubMedSearchData, + PubMedSearchResponse, +) + +logger = logging.getLogger("humcp.tools.pubmed") + +ESEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi" +EFETCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi" + + +def _build_esearch_params( + query: str, + max_results: int, + api_key: str | None = None, + sort: str | None = None, + date_type: str | None = None, + min_date: str | None = None, + max_date: str | None = None, + rel_date: int | None = None, +) -> dict: + """Build query parameters for the E-Search API. + + Args: + query: Search query string. + max_results: Maximum number of results. + api_key: Optional NCBI API key. + sort: Sort order. Options: "most+recent", "pub+date", "journal". + date_type: Date type for filtering. Options: "pdat" (publication), "edat" (Entrez), "mdat" (modification). + min_date: Start date in YYYY/MM/DD format (requires max_date). + max_date: End date in YYYY/MM/DD format (requires min_date). + rel_date: Relative date - limit to items within last N days. + """ + params: dict = { + "db": "pubmed", + "term": query, + "retmax": max_results, + "usehistory": "y", + } + if api_key: + params["api_key"] = api_key + if sort: + params["sort"] = sort + if date_type: + params["datetype"] = date_type + if min_date and max_date: + params["mindate"] = min_date + params["maxdate"] = max_date + if rel_date is not None: + params["reldate"] = str(rel_date) + return params + + +async def _fetch_pubmed_ids( + query: str, + max_results: int, + api_key: str | None = None, + sort: str | None = None, + date_type: str | None = None, + min_date: str | None = None, + max_date: str | None = None, + rel_date: int | None = None, +) -> list[str]: + """Search PubMed and return matching PMIDs.""" + params = _build_esearch_params( + query, + max_results, + api_key=api_key, + sort=sort, + date_type=date_type, + min_date=min_date, + max_date=max_date, + rel_date=rel_date, + ) + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(ESEARCH_URL, params=params) + response.raise_for_status() + root = ElementTree.fromstring(response.content) + return [ + id_elem.text for id_elem in root.findall(".//Id") if id_elem.text is not None + ] + + +async def _fetch_article_details( + pmids: list[str], api_key: str | None = None +) -> ElementTree.Element: + """Fetch detailed article XML from PubMed for given PMIDs.""" + params: dict = { + "db": "pubmed", + "id": ",".join(pmids), + "retmode": "xml", + } + if api_key: + params["api_key"] = api_key + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(EFETCH_URL, params=params) + response.raise_for_status() + return ElementTree.fromstring(response.content) + + +def _parse_article(article_elem: ElementTree.Element) -> PubMedArticle: + """Parse a single PubmedArticle XML element into a PubMedArticle model.""" + # PMID + pmid_elem = article_elem.find(".//PMID") + pmid = pmid_elem.text if pmid_elem is not None and pmid_elem.text else "" + + # Title + title_elem = article_elem.find(".//ArticleTitle") + title = ( + title_elem.text + if title_elem is not None and title_elem.text + else "No title available" + ) + + # Abstract + abstract_sections = article_elem.findall(".//AbstractText") + if abstract_sections: + parts: list[str] = [] + for section in abstract_sections: + label = section.get("Label", "") + text = section.text or "" + if label: + parts.append(f"{label}: {text}") + else: + parts.append(text) + abstract = "\n\n".join(parts).strip() + else: + abstract = "No abstract available" + + # First author + first_author_elem = article_elem.find(".//AuthorList/Author[1]") + first_author = "Unknown" + if first_author_elem is not None: + last_name = first_author_elem.find("LastName") + fore_name = first_author_elem.find("ForeName") + if ( + last_name is not None + and last_name.text + and fore_name is not None + and fore_name.text + ): + first_author = f"{last_name.text}, {fore_name.text}" + elif last_name is not None and last_name.text: + first_author = last_name.text + + # Journal + journal_elem = article_elem.find(".//Journal/Title") + journal = ( + journal_elem.text + if journal_elem is not None and journal_elem.text + else "Unknown Journal" + ) + + # Published year + pub_date = article_elem.find(".//PubDate/Year") + published = ( + pub_date.text if pub_date is not None and pub_date.text else "No date available" + ) + + # DOI + doi_elem = article_elem.find(".//ArticleIdList/ArticleId[@IdType='doi']") + doi = doi_elem.text if doi_elem is not None and doi_elem.text else None + + # PubMed URL + pubmed_url = ( + f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else "No URL available" + ) + + # Full text URL + pmc_elem = article_elem.find(".//ArticleIdList/ArticleId[@IdType='pmc']") + full_text_url: str | None = None + if pmc_elem is not None and pmc_elem.text: + full_text_url = f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmc_elem.text}/" + elif doi: + full_text_url = f"https://doi.org/{doi}" + + # Keywords + keywords = [ + kw.text for kw in article_elem.findall(".//KeywordList/Keyword") if kw.text + ] + + # MeSH terms + mesh_terms = [ + mesh.text + for mesh in article_elem.findall(".//MeshHeading/DescriptorName") + if mesh.text + ] + + # Publication types + pub_types = [ + pt.text + for pt in article_elem.findall(".//PublicationTypeList/PublicationType") + if pt.text + ] + + return PubMedArticle( + pmid=pmid, + title=title, + abstract=abstract, + first_author=first_author, + journal=journal, + published=published, + doi=doi, + pubmed_url=pubmed_url, + full_text_url=full_text_url, + keywords=keywords, + mesh_terms=mesh_terms, + publication_types=pub_types, + ) + + +@tool() +async def pubmed_search( + query: str, + max_results: int = 10, + sort: str | None = None, + date_type: str | None = None, + min_date: str | None = None, + max_date: str | None = None, + rel_date: int | None = None, +) -> PubMedSearchResponse: + """Search PubMed for biomedical and life sciences articles. + + Supports NCBI E-utilities search syntax including MeSH terms, boolean + operators (AND, OR, NOT), and field tags like [Title], [Author], [Journal]. + + Args: + query: The search query for PubMed. Supports field tags like "cancer[Title] AND 2024[Date - Publication]". + max_results: Maximum number of results to return (default 10, max 200). + sort: Sort order for results. Options: "most+recent" (default PubMed ordering), "pub+date" (by publication date), "journal" (alphabetical by journal). + date_type: Date field to filter on. Options: "pdat" (publication date), "edat" (Entrez date), "mdat" (modification date). + min_date: Start date for date range filter in YYYY/MM/DD, YYYY/MM, or YYYY format. Requires max_date. + max_date: End date for date range filter in YYYY/MM/DD, YYYY/MM, or YYYY format. Requires min_date. + rel_date: Limit to items published within the last N days. Overrides min_date/max_date. + + Returns: + Search results with article metadata including title, abstract, authors, and links. + """ + try: + if max_results < 1: + return PubMedSearchResponse( + success=False, error="max_results must be at least 1" + ) + + max_results = min(max_results, 200) + + if sort and sort not in {"most+recent", "pub+date", "journal"}: + return PubMedSearchResponse( + success=False, + error=f"Invalid sort '{sort}'. Must be one of: most+recent, pub+date, journal", + ) + + if date_type and date_type not in {"pdat", "edat", "mdat"}: + return PubMedSearchResponse( + success=False, + error=f"Invalid date_type '{date_type}'. Must be one of: pdat, edat, mdat", + ) + + if (min_date and not max_date) or (max_date and not min_date): + return PubMedSearchResponse( + success=False, + error="Both min_date and max_date must be provided together.", + ) + + logger.info( + "PubMed search query_length=%d max_results=%d sort=%s", + len(query), + max_results, + sort, + ) + + ncbi_api_key = await resolve_credential("NCBI_API_KEY") + pmids = await _fetch_pubmed_ids( + query, + max_results, + api_key=ncbi_api_key, + sort=sort, + date_type=date_type, + min_date=min_date, + max_date=max_date, + rel_date=rel_date, + ) + if not pmids: + return PubMedSearchResponse( + success=True, + data=PubMedSearchData(query=query, results=[]), + ) + + xml_root = await _fetch_article_details(pmids, api_key=ncbi_api_key) + articles: list[PubMedArticle] = [] + for article_elem in xml_root.findall(".//PubmedArticle"): + try: + articles.append(_parse_article(article_elem)) + except Exception as e: + logger.warning("Skipping article due to parse error: %s", e) + + logger.info("PubMed search complete results=%d", len(articles)) + + return PubMedSearchResponse( + success=True, + data=PubMedSearchData(query=query, results=articles), + ) + except Exception as e: + logger.exception("PubMed search failed") + return PubMedSearchResponse( + success=False, error=f"PubMed search failed: {str(e)}" + ) + + +@tool() +async def pubmed_get_article( + pmid: str, +) -> PubMedGetArticleResponse: + """Fetch a single PubMed article by its PMID. + + Args: + pmid: The PubMed ID of the article to retrieve. + + Returns: + Full article metadata including title, abstract, authors, and links. + """ + try: + if not pmid.strip(): + return PubMedGetArticleResponse( + success=False, error="pmid must not be empty" + ) + + logger.info("PubMed get article pmid=%s", pmid) + + ncbi_api_key = await resolve_credential("NCBI_API_KEY") + xml_root = await _fetch_article_details([pmid.strip()], api_key=ncbi_api_key) + article_elems = xml_root.findall(".//PubmedArticle") + if not article_elems: + return PubMedGetArticleResponse( + success=False, error=f"No article found for PMID: {pmid}" + ) + + article = _parse_article(article_elems[0]) + + logger.info("PubMed get article complete title=%s", article.title) + + return PubMedGetArticleResponse( + success=True, + data=PubMedGetArticleData(article=article), + ) + except Exception as e: + logger.exception("PubMed get article failed") + return PubMedGetArticleResponse( + success=False, error=f"PubMed get article failed: {str(e)}" + ) diff --git a/src/tools/research/schemas.py b/src/tools/research/schemas.py new file mode 100644 index 0000000..5edb13f --- /dev/null +++ b/src/tools/research/schemas.py @@ -0,0 +1,237 @@ +"""Pydantic output schemas for research tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# ArXiv Schemas +# ============================================================================= + + +class ArxivArticle(BaseModel): + """A single article from arXiv search results.""" + + title: str = Field(..., description="Title of the article") + article_id: str = Field(..., description="Short arXiv ID (e.g. '2103.03404v1')") + entry_id: str = Field(..., description="Full arXiv entry URL") + authors: list[str] = Field(default_factory=list, description="List of author names") + primary_category: str = Field(..., description="Primary arXiv category") + categories: list[str] = Field( + default_factory=list, description="All arXiv categories" + ) + published: str | None = Field(None, description="Publication date in ISO format") + pdf_url: str | None = Field(None, description="URL to the PDF") + summary: str = Field(..., description="Article abstract/summary") + comment: str | None = Field(None, description="Author comment if available") + + +class ArxivSearchData(BaseModel): + """Output data for arxiv_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[ArxivArticle] = Field( + default_factory=list, description="List of matching articles" + ) + + +class ArxivPageContent(BaseModel): + """Content from a single PDF page.""" + + page: int = Field(..., description="Page number (1-indexed)") + text: str = Field(..., description="Extracted text from the page") + + +class ArxivPaperContent(BaseModel): + """Full content of an arXiv paper.""" + + title: str = Field(..., description="Title of the paper") + article_id: str = Field(..., description="Short arXiv ID") + authors: list[str] = Field(default_factory=list, description="List of author names") + summary: str = Field(..., description="Paper abstract/summary") + pages: list[ArxivPageContent] = Field( + default_factory=list, description="Extracted page content" + ) + + +class ArxivReadPaperData(BaseModel): + """Output data for arxiv_read_paper tool.""" + + papers: list[ArxivPaperContent] = Field( + default_factory=list, description="List of papers with extracted content" + ) + + +class ArxivGetPaperData(BaseModel): + """Output data for arxiv_get_paper tool.""" + + article: ArxivArticle = Field(..., description="The fetched article metadata") + + +# ============================================================================= +# PubMed Schemas +# ============================================================================= + + +class PubMedArticle(BaseModel): + """A single article from PubMed search results.""" + + pmid: str = Field(..., description="PubMed ID") + title: str = Field(..., description="Article title") + abstract: str = Field(..., description="Article abstract") + first_author: str = Field(..., description="First author name") + journal: str = Field(..., description="Journal name") + published: str = Field(..., description="Publication year") + doi: str | None = Field(None, description="Digital Object Identifier") + pubmed_url: str = Field(..., description="PubMed URL") + full_text_url: str | None = Field(None, description="Full text URL if available") + keywords: list[str] = Field(default_factory=list, description="Article keywords") + mesh_terms: list[str] = Field(default_factory=list, description="MeSH terms") + publication_types: list[str] = Field( + default_factory=list, description="Publication types" + ) + + +class PubMedSearchData(BaseModel): + """Output data for pubmed_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[PubMedArticle] = Field( + default_factory=list, description="List of matching articles" + ) + + +class PubMedGetArticleData(BaseModel): + """Output data for pubmed_get_article tool.""" + + article: PubMedArticle = Field(..., description="The fetched article") + + +# ============================================================================= +# Wikipedia Schemas +# ============================================================================= + + +class WikipediaSearchResult(BaseModel): + """A single result from Wikipedia search.""" + + title: str = Field(..., description="Page title") + snippet: str = Field(..., description="Short summary or snippet") + url: str = Field(..., description="URL to the Wikipedia page") + + +class WikipediaSearchData(BaseModel): + """Output data for wikipedia_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[WikipediaSearchResult] = Field( + default_factory=list, description="List of search results" + ) + + +class WikipediaPageData(BaseModel): + """Output data for wikipedia_get_page tool.""" + + title: str = Field(..., description="Page title") + url: str = Field(..., description="URL to the Wikipedia page") + summary: str = Field(..., description="Page summary") + content: str = Field(..., description="Full page content") + categories: list[str] = Field(default_factory=list, description="Page categories") + references: list[str] = Field( + default_factory=list, description="Page reference URLs" + ) + + +class WikipediaSummaryData(BaseModel): + """Output data for wikipedia_get_summary tool.""" + + title: str = Field(..., description="Page title") + url: str = Field(..., description="URL to the Wikipedia page") + summary: str = Field(..., description="Page summary text") + language: str = Field("en", description="Wikipedia language code used") + + +class WikipediaRandomData(BaseModel): + """Output data for wikipedia_get_random tool.""" + + title: str = Field(..., description="Random page title") + url: str = Field(..., description="URL to the Wikipedia page") + summary: str = Field(..., description="Page summary text") + language: str = Field("en", description="Wikipedia language code used") + + +class ArxivRelatedData(BaseModel): + """Output data for arxiv_get_related tool.""" + + source_paper_id: str = Field( + ..., description="The source paper ID used to find related papers" + ) + source_title: str = Field(..., description="Title of the source paper") + results: list[ArxivArticle] = Field( + default_factory=list, description="List of related articles" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ArxivSearchResponse(ToolResponse[ArxivSearchData]): + """Response schema for arxiv_search tool.""" + + pass + + +class ArxivReadPaperResponse(ToolResponse[ArxivReadPaperData]): + """Response schema for arxiv_read_paper tool.""" + + pass + + +class PubMedSearchResponse(ToolResponse[PubMedSearchData]): + """Response schema for pubmed_search tool.""" + + pass + + +class PubMedGetArticleResponse(ToolResponse[PubMedGetArticleData]): + """Response schema for pubmed_get_article tool.""" + + pass + + +class WikipediaSearchResponse(ToolResponse[WikipediaSearchData]): + """Response schema for wikipedia_search tool.""" + + pass + + +class WikipediaGetPageResponse(ToolResponse[WikipediaPageData]): + """Response schema for wikipedia_get_page tool.""" + + pass + + +class WikipediaGetSummaryResponse(ToolResponse[WikipediaSummaryData]): + """Response schema for wikipedia_get_summary tool.""" + + pass + + +class ArxivGetPaperResponse(ToolResponse[ArxivGetPaperData]): + """Response schema for arxiv_get_paper tool.""" + + pass + + +class WikipediaRandomResponse(ToolResponse[WikipediaRandomData]): + """Response schema for wikipedia_get_random tool.""" + + pass + + +class ArxivRelatedResponse(ToolResponse[ArxivRelatedData]): + """Response schema for arxiv_get_related tool.""" + + pass diff --git a/src/tools/research/wikipedia.py b/src/tools/research/wikipedia.py new file mode 100644 index 0000000..deac85c --- /dev/null +++ b/src/tools/research/wikipedia.py @@ -0,0 +1,223 @@ +"""Wikipedia search and page retrieval tools.""" + +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.research.schemas import ( + WikipediaGetPageResponse, + WikipediaGetSummaryResponse, + WikipediaPageData, + WikipediaSearchData, + WikipediaSearchResponse, + WikipediaSearchResult, + WikipediaSummaryData, +) + +try: + import wikipedia +except ImportError as err: + raise ImportError( + "wikipedia is required for Wikipedia tools. Install with: pip install wikipedia" + ) from err + +logger = logging.getLogger("humcp.tools.wikipedia") + + +@tool() +async def wikipedia_search( + query: str, + max_results: int = 5, + language: str = "en", +) -> WikipediaSearchResponse: + """Search Wikipedia for pages matching a query. + + Args: + query: The search query to find Wikipedia pages. + max_results: Maximum number of results to return (default 5, max 20). + language: Wikipedia language code (e.g. "en", "fr", "de", "es", "ja", "zh"). Default is "en". + + Returns: + A list of matching Wikipedia page titles with summaries. + """ + try: + if max_results < 1: + return WikipediaSearchResponse( + success=False, error="max_results must be at least 1" + ) + + max_results = min(max_results, 20) + + logger.info( + "Wikipedia search query_length=%d max_results=%d lang=%s", + len(query), + max_results, + language, + ) + + wikipedia.set_lang(language) + titles = wikipedia.search(query, results=max_results) + + results: list[WikipediaSearchResult] = [] + for title in titles: + try: + summary = wikipedia.summary(title, sentences=2) + url = wikipedia.page(title, auto_suggest=False).url + results.append( + WikipediaSearchResult( + title=title, + snippet=summary, + url=url, + ) + ) + except wikipedia.exceptions.DisambiguationError as e: + lang_prefix = language if language != "en" else "en" + results.append( + WikipediaSearchResult( + title=title, + snippet=f"Disambiguation page. Options: {', '.join(e.options[:5])}", + url=f"https://{lang_prefix}.wikipedia.org/wiki/{title.replace(' ', '_')}", + ) + ) + except wikipedia.exceptions.PageError: + logger.warning("Wikipedia page not found for title: %s", title) + + logger.info("Wikipedia search complete results=%d", len(results)) + + return WikipediaSearchResponse( + success=True, + data=WikipediaSearchData(query=query, results=results), + ) + except Exception as e: + logger.exception("Wikipedia search failed") + return WikipediaSearchResponse( + success=False, error=f"Wikipedia search failed: {str(e)}" + ) + + +@tool() +async def wikipedia_get_page( + title: str, + language: str = "en", +) -> WikipediaGetPageResponse: + """Retrieve full content of a Wikipedia page by title. + + Args: + title: The exact title of the Wikipedia page to retrieve. + language: Wikipedia language code (e.g. "en", "fr", "de", "es", "ja", "zh"). Default is "en". + + Returns: + Full page content including summary, text, categories, and references. + """ + try: + if not title.strip(): + return WikipediaGetPageResponse( + success=False, error="title must not be empty" + ) + + logger.info("Wikipedia get page title=%s lang=%s", title, language) + + wikipedia.set_lang(language) + + try: + page = wikipedia.page(title, auto_suggest=False) + except wikipedia.exceptions.PageError: + return WikipediaGetPageResponse( + success=False, error=f"Wikipedia page not found: {title}" + ) + except wikipedia.exceptions.DisambiguationError as e: + return WikipediaGetPageResponse( + success=False, + error=f"'{title}' is a disambiguation page. Try one of: {', '.join(e.options[:10])}", + ) + + data = WikipediaPageData( + title=page.title, + url=page.url, + summary=page.summary, + content=page.content, + categories=list(page.categories), + references=list(page.references)[:50], + ) + + logger.info( + "Wikipedia get page complete title=%s content_length=%d", + page.title, + len(page.content), + ) + + return WikipediaGetPageResponse(success=True, data=data) + except Exception as e: + logger.exception("Wikipedia get page failed") + return WikipediaGetPageResponse( + success=False, error=f"Wikipedia get page failed: {str(e)}" + ) + + +@tool() +async def wikipedia_get_summary( + title: str, + sentences: int = 5, + language: str = "en", +) -> WikipediaGetSummaryResponse: + """Get a concise summary of a Wikipedia page. + + Returns just the summary/introduction text without the full page content, + making it faster and more lightweight than wikipedia_get_page. + + Args: + title: The title of the Wikipedia page. + sentences: Number of sentences to include in the summary (default 5, max 20). + language: Wikipedia language code (e.g. "en", "fr", "de", "es", "ja", "zh"). Default is "en". + + Returns: + A short summary of the page or an error message. + """ + try: + if not title.strip(): + return WikipediaGetSummaryResponse( + success=False, error="title must not be empty" + ) + + sentences = max(1, min(sentences, 20)) + logger.info( + "Wikipedia get summary title=%s sentences=%d lang=%s", + title, + sentences, + language, + ) + + wikipedia.set_lang(language) + + try: + summary_text = wikipedia.summary( + title, sentences=sentences, auto_suggest=False + ) + page = wikipedia.page(title, auto_suggest=False) + url = page.url + resolved_title = page.title + except wikipedia.exceptions.PageError: + return WikipediaGetSummaryResponse( + success=False, error=f"Wikipedia page not found: {title}" + ) + except wikipedia.exceptions.DisambiguationError as e: + return WikipediaGetSummaryResponse( + success=False, + error=f"'{title}' is a disambiguation page. Try one of: {', '.join(e.options[:10])}", + ) + + return WikipediaGetSummaryResponse( + success=True, + data=WikipediaSummaryData( + title=resolved_title, + url=url, + summary=summary_text, + language=language, + ), + ) + except Exception as e: + logger.exception("Wikipedia get summary failed") + return WikipediaGetSummaryResponse( + success=False, error=f"Wikipedia get summary failed: {str(e)}" + ) diff --git a/src/tools/search/baidu.py b/src/tools/search/baidu.py new file mode 100644 index 0000000..7a8df72 --- /dev/null +++ b/src/tools/search/baidu.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + SearchResult, + WebSearchData, + WebSearchResponse, +) + +try: + from baidusearch.baidusearch import search as baidu_raw_search # type: ignore +except ImportError as err: + raise ImportError( + "baidusearch is required for Baidu search tools. " + "Install with: pip install baidusearch" + ) from err + +logger = logging.getLogger("humcp.tools.baidu") + + +@tool() +async def baidu_search( + query: str, + max_results: int = 5, +) -> WebSearchResponse: + """Search the web using Baidu, the leading Chinese search engine. + + Args: + query: The search query (supports Chinese and English). + max_results: Maximum number of results to return. + + Returns: + Search results with titles, URLs, and snippets. + """ + try: + if not query: + return WebSearchResponse( + success=False, error="Please provide a query to search for." + ) + + if max_results < 1: + return WebSearchResponse( + success=False, error="max_results must be at least 1" + ) + + logger.info( + "Baidu search query_length=%d max_results=%d", len(query), max_results + ) + + raw_results = baidu_raw_search(keyword=query, num_results=max_results) + + search_results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("url", ""), + snippet=r.get("abstract", ""), + ) + for r in raw_results + ] + + data = WebSearchData( + query=query, + results=search_results, + total_results=len(search_results), + ) + + logger.info("Baidu search complete results=%d", len(search_results)) + return WebSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Baidu search failed") + return WebSearchResponse(success=False, error=f"Baidu search failed: {str(e)}") diff --git a/src/tools/search/brave.py b/src/tools/search/brave.py new file mode 100644 index 0000000..7c64d64 --- /dev/null +++ b/src/tools/search/brave.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + BraveImageResult, + BraveImagesData, + BraveImagesResponse, + BraveNewsData, + BraveNewsResponse, + BraveNewsResult, + SearchResult, + WebSearchData, + WebSearchResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Brave Search tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.brave") + +BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search" +BRAVE_NEWS_URL = "https://api.search.brave.com/res/v1/news/search" +BRAVE_IMAGES_URL = "https://api.search.brave.com/res/v1/images/search" + + +@tool() +async def brave_web_search( + query: str, + count: int = 5, + country: str = "US", + search_lang: str = "en", +) -> WebSearchResponse: + """Search the web using the Brave Search API. + + Args: + query: The search query to look up. + count: Number of results to return (max 20). + country: Country code for search results (e.g., "US", "GB"). + search_lang: Language code for search results (e.g., "en", "fr"). + + Returns: + Search results with titles, URLs, and snippets. + """ + try: + api_key = await resolve_credential("BRAVE_API_KEY") + if not api_key: + return WebSearchResponse( + success=False, + error="Brave Search API not configured. Set BRAVE_API_KEY environment variable.", + ) + + if not query: + return WebSearchResponse( + success=False, error="Please provide a query to search for." + ) + + if count < 1: + return WebSearchResponse(success=False, error="count must be at least 1") + + logger.info( + "Brave search query_length=%d count=%d country=%s", + len(query), + count, + country, + ) + + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": api_key, + } + params: dict[str, str | int] = { + "q": query, + "count": min(count, 20), + "country": country, + "search_lang": search_lang, + "result_filter": "web", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + BRAVE_SEARCH_URL, + headers=headers, + params=params, + timeout=30.0, # type: ignore[arg-type] + ) + response.raise_for_status() + response_data = response.json() + + search_results = [] + web_results = response_data.get("web", {}).get("results", []) + for r in web_results: + search_results.append( + SearchResult( + title=r.get("title", ""), + url=str(r.get("url", "")), + snippet=r.get("description", ""), + ) + ) + + data = WebSearchData( + query=query, + results=search_results, + total_results=len(search_results), + ) + + logger.info("Brave search complete results=%d", len(search_results)) + return WebSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Brave search failed") + return WebSearchResponse(success=False, error=f"Brave search failed: {str(e)}") + + +@tool() +async def brave_news_search( + query: str, + count: int = 5, + country: str = "US", + search_lang: str = "en", + freshness: str | None = None, +) -> BraveNewsResponse: + """Search for news articles using the Brave Search News API. + + Args: + query: The news search query. + count: Number of results to return (max 20). + country: Country code for results (e.g., "US", "GB"). + search_lang: Language code (e.g., "en", "fr"). + freshness: Time filter: "pd" (past day), "pw" (past week), "pm" (past month), "py" (past year). + + Returns: + News results with titles, URLs, descriptions, and sources. + """ + try: + api_key = await resolve_credential("BRAVE_API_KEY") + if not api_key: + return BraveNewsResponse( + success=False, + error="Brave Search API not configured. Set BRAVE_API_KEY environment variable.", + ) + + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": api_key, + } + params: dict = { + "q": query, + "count": min(count, 20), + "country": country, + "search_lang": search_lang, + } + if freshness: + params["freshness"] = freshness + + async with httpx.AsyncClient() as client: + response = await client.get( + BRAVE_NEWS_URL, headers=headers, params=params, timeout=30.0 + ) + response.raise_for_status() + response_data = response.json() + + news_results = [] + for r in response_data.get("results", []): + news_results.append( + BraveNewsResult( + title=r.get("title", ""), + url=str(r.get("url", "")), + description=r.get("description", ""), + age=r.get("age"), + source=r.get("meta_url", {}).get("hostname") + if isinstance(r.get("meta_url"), dict) + else None, + ) + ) + + data = BraveNewsData(query=query, results=news_results) + return BraveNewsResponse(success=True, data=data) + except Exception as e: + logger.exception("Brave news search failed") + return BraveNewsResponse( + success=False, error=f"Brave news search failed: {str(e)}" + ) + + +@tool() +async def brave_image_search( + query: str, + count: int = 5, + country: str = "US", + search_lang: str = "en", + safesearch: str = "moderate", +) -> BraveImagesResponse: + """Search for images using the Brave Search Images API. + + Args: + query: The image search query. + count: Number of results to return (max 20). + country: Country code for results (e.g., "US", "GB"). + search_lang: Language code (e.g., "en", "fr"). + safesearch: Safe search level: "off", "moderate", "strict". + + Returns: + Image results with titles, page URLs, and thumbnail URLs. + """ + try: + api_key = await resolve_credential("BRAVE_API_KEY") + if not api_key: + return BraveImagesResponse( + success=False, + error="Brave Search API not configured. Set BRAVE_API_KEY environment variable.", + ) + + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": api_key, + } + params: dict[str, str | int] = { + "q": query, + "count": min(count, 20), + "country": country, + "search_lang": search_lang, + "safesearch": safesearch, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + BRAVE_IMAGES_URL, + headers=headers, + params=params, + timeout=30.0, # type: ignore[arg-type] + ) + response.raise_for_status() + response_data = response.json() + + image_results = [] + for r in response_data.get("results", []): + image_results.append( + BraveImageResult( + title=r.get("title", ""), + url=str(r.get("url", "")), + thumbnail_url=r.get("thumbnail", {}).get("src", "") + if isinstance(r.get("thumbnail"), dict) + else "", + source=r.get("source"), + ) + ) + + data = BraveImagesData(query=query, results=image_results) + return BraveImagesResponse(success=True, data=data) + except Exception as e: + logger.exception("Brave image search failed") + return BraveImagesResponse( + success=False, error=f"Brave image search failed: {str(e)}" + ) diff --git a/src/tools/search/duckduckgo.py b/src/tools/search/duckduckgo.py new file mode 100644 index 0000000..e3f7bde --- /dev/null +++ b/src/tools/search/duckduckgo.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import logging +import os + +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + DuckDuckGoImageResult, + DuckDuckGoImagesData, + DuckDuckGoImagesResponse, + DuckDuckGoNewsData, + DuckDuckGoNewsResponse, + DuckDuckGoNewsResult, + SearchResult, + WebSearchData, + WebSearchResponse, +) + +try: + from duckduckgo_search import DDGS +except ImportError as err: + raise ImportError( + "duckduckgo-search is required for DuckDuckGo tools. " + "Install with: pip install duckduckgo-search" + ) from err + +logger = logging.getLogger("humcp.tools.duckduckgo") + + +@tool() +async def duckduckgo_search( + query: str, + max_results: int = 5, + timelimit: str | None = None, + region: str = "wt-wt", +) -> WebSearchResponse: + """Search the web using DuckDuckGo and return results. + + Args: + query: The search query to look up. + max_results: Maximum number of results to return. + timelimit: Time limit for results: "d" (day), "w" (week), "m" (month), "y" (year). + region: Region for search results (e.g., "wt-wt" for worldwide, "us-en", "uk-en"). + + Returns: + Search results with titles, URLs, and snippets. + """ + try: + if max_results < 1: + return WebSearchResponse( + success=False, error="max_results must be at least 1" + ) + + proxy = os.getenv("DUCKDUCKGO_PROXY") + logger.info( + "DuckDuckGo search query_length=%d max_results=%d region=%s", + len(query), + max_results, + region, + ) + + with DDGS(proxy=proxy) as ddgs: + raw_results = list( + ddgs.text( + keywords=query, + region=region, + timelimit=timelimit, + max_results=max_results, + ) + ) + + search_results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("href", r.get("link", "")), + snippet=r.get("body", r.get("snippet", "")), + ) + for r in raw_results + ] + + data = WebSearchData( + query=query, + results=search_results, + total_results=len(search_results), + ) + + logger.info("DuckDuckGo search complete results=%d", len(search_results)) + return WebSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("DuckDuckGo search failed") + return WebSearchResponse( + success=False, error=f"DuckDuckGo search failed: {str(e)}" + ) + + +@tool() +async def duckduckgo_news( + query: str, + max_results: int = 5, + timelimit: str | None = None, + region: str = "wt-wt", +) -> DuckDuckGoNewsResponse: + """Search for news articles using DuckDuckGo News. + + Args: + query: The news search query. + max_results: Maximum number of news results to return. + timelimit: Time limit for results: "d" (day), "w" (week), "m" (month). + region: Region for search results (e.g., "wt-wt" for worldwide, "us-en"). + + Returns: + News results with titles, URLs, snippets, sources, and dates. + """ + try: + if max_results < 1: + return DuckDuckGoNewsResponse( + success=False, error="max_results must be at least 1" + ) + + proxy = os.getenv("DUCKDUCKGO_PROXY") + logger.info( + "DuckDuckGo news search query_length=%d max_results=%d", + len(query), + max_results, + ) + + with DDGS(proxy=proxy) as ddgs: + raw_results = list( + ddgs.news( + keywords=query, + region=region, + timelimit=timelimit, + max_results=max_results, + ) + ) + + news_results = [ + DuckDuckGoNewsResult( + title=r.get("title", ""), + url=r.get("url", r.get("link", "")), + snippet=r.get("body", r.get("snippet", "")), + source=r.get("source"), + date=r.get("date"), + ) + for r in raw_results + ] + + data = DuckDuckGoNewsData(query=query, results=news_results) + + logger.info("DuckDuckGo news search complete results=%d", len(news_results)) + return DuckDuckGoNewsResponse(success=True, data=data) + except Exception as e: + logger.exception("DuckDuckGo news search failed") + return DuckDuckGoNewsResponse( + success=False, error=f"DuckDuckGo news search failed: {str(e)}" + ) + + +@tool() +async def duckduckgo_images( + query: str, + max_results: int = 5, + region: str = "wt-wt", + safesearch: str = "moderate", + size: str | None = None, + color: str | None = None, + type_image: str | None = None, +) -> DuckDuckGoImagesResponse: + """Search for images using DuckDuckGo. + + Args: + query: The image search query. + max_results: Maximum number of image results to return. + region: Region for results (e.g., "wt-wt" worldwide, "us-en"). + safesearch: Safe search level: "on", "moderate", "off". + size: Filter by size: "Small", "Medium", "Large", "Wallpaper". + color: Filter by color: "color", "Monochrome", "Red", "Orange", "Yellow", + "Green", "Blue", "Purple", "Pink", "Brown", "Black", "Gray", "Teal", "White". + type_image: Filter by type: "photo", "clipart", "gif", "transparent", "line". + + Returns: + Image results with titles, page URLs, image URLs, and dimensions. + """ + try: + if max_results < 1: + return DuckDuckGoImagesResponse( + success=False, error="max_results must be at least 1" + ) + + proxy = os.getenv("DUCKDUCKGO_PROXY") + logger.info( + "DuckDuckGo images query_length=%d max_results=%d", len(query), max_results + ) + + with DDGS(proxy=proxy) as ddgs: + raw_results = list( + ddgs.images( + keywords=query, + region=region, + safesearch=safesearch, + size=size, + color=color, + type_image=type_image, + max_results=max_results, + ) + ) + + image_results = [ + DuckDuckGoImageResult( + title=r.get("title", ""), + url=r.get("url", ""), + image_url=r.get("image", ""), + thumbnail_url=r.get("thumbnail", ""), + source=r.get("source"), + width=int(r["width"]) if r.get("width") else None, + height=int(r["height"]) if r.get("height") else None, + ) + for r in raw_results + ] + + data = DuckDuckGoImagesData(query=query, results=image_results) + logger.info("DuckDuckGo images complete results=%d", len(image_results)) + return DuckDuckGoImagesResponse(success=True, data=data) + except Exception as e: + logger.exception("DuckDuckGo images search failed") + return DuckDuckGoImagesResponse( + success=False, error=f"DuckDuckGo images search failed: {str(e)}" + ) diff --git a/src/tools/search/exa.py b/src/tools/search/exa.py new file mode 100644 index 0000000..dc79a5d --- /dev/null +++ b/src/tools/search/exa.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import logging +from typing import Literal + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + ExaAnswerData, + ExaAnswerResponse, + ExaFindSimilarData, + ExaFindSimilarResponse, + ExaGetContentsData, + ExaGetContentsResponse, + ExaSearchData, + ExaSearchResponse, + ExaSearchResult, +) + +try: + from exa_py import Exa + from exa_py.api import ( + ContentsOptions, + HighlightsContentsOptions, + TextContentsOptions, + ) +except ImportError as err: + raise ImportError( + "exa_py is required for Exa search tools. Install with: pip install exa_py" + ) from err + +logger = logging.getLogger("humcp.tools.exa") + + +def _build_result(result: object, max_characters: int) -> ExaSearchResult: + """Extract fields from an Exa SDK result object into our schema.""" + text = getattr(result, "text", "") or "" + if max_characters and len(text) > max_characters: + text = text[:max_characters] + + raw_highlights = getattr(result, "highlights", None) + highlights = list(raw_highlights) if raw_highlights else None + + return ExaSearchResult( + title=getattr(result, "title", "") or "", + url=getattr(result, "url", "") or "", + snippet=text, + highlights=highlights, + author=getattr(result, "author", None), + published_date=getattr(result, "published_date", None), + score=getattr(result, "score", None), + ) + + +def _build_contents( + content_type: str, + max_characters: int, + livecrawl: str | None = None, +) -> ContentsOptions: + """Build the contents parameter for Exa API calls.""" + opts = ContentsOptions() + if content_type == "highlights": + opts["highlights"] = HighlightsContentsOptions(max_characters=max_characters) + else: + opts["text"] = TextContentsOptions(max_characters=max_characters) + if livecrawl: + opts["livecrawl"] = livecrawl # type: ignore[typeddict-item] + return opts + + +@tool() +async def exa_search( + query: str, + num_results: int = 10, + search_type: Literal["auto", "fast"] = "auto", + content_type: Literal["highlights", "text"] = "highlights", + max_characters: int = 4000, + category: str | None = None, + include_domains: list[str] | None = None, + exclude_domains: list[str] | None = None, + livecrawl: Literal["preferred", "always", "never"] | None = None, +) -> ExaSearchResponse: + """Search the web using Exa, an AI-native search engine. + + Args: + query: The search query to look up. + num_results: Number of results to return (max 100). + search_type: Search mode — "auto" for balanced relevance and speed, + "fast" for real-time apps and quick lookups. + content_type: Content extraction mode — "highlights" for key excerpts + (lower cost), "text" for full contiguous content (higher cost). + max_characters: Maximum characters of content per result. + category: Filter results by category. Options: "company", "research paper", + "news", "tweet", "people", "pdf", "github", "personal site", + "linkedin profile", "financial report". + include_domains: Only return results from these domains (e.g. ["arxiv.org"]). + exclude_domains: Exclude results from these domains (e.g. ["pinterest.com"]). + livecrawl: Content freshness — "preferred" to livecrawl if cache is stale, + "always" to always livecrawl, "never" for cache only. + + Returns: + Search results with titles, URLs, content, and metadata. + """ + try: + api_key = await resolve_credential("EXA_API_KEY") + if not api_key: + return ExaSearchResponse( + success=False, + error="Exa API not configured. Set EXA_API_KEY environment variable.", + ) + + if not query: + return ExaSearchResponse( + success=False, error="Please provide a query to search for." + ) + + logger.info( + "Exa search query_length=%d num_results=%d type=%s content=%s", + len(query), + num_results, + search_type, + content_type, + ) + + exa_client = Exa(api_key) + + search_kwargs: dict = { + "num_results": num_results, + "type": search_type, + "contents": _build_contents(content_type, max_characters, livecrawl), + } + if category: + search_kwargs["category"] = category + if include_domains: + search_kwargs["include_domains"] = include_domains + if exclude_domains: + search_kwargs["exclude_domains"] = exclude_domains + + exa_results = exa_client.search(query, **search_kwargs) + + search_results = [_build_result(r, max_characters) for r in exa_results.results] + + autoprompt = getattr(exa_results, "autoprompt_string", None) + + data = ExaSearchData( + query=query, + results=search_results, + autoprompt_string=autoprompt, + ) + + logger.info("Exa search complete results=%d", len(search_results)) + return ExaSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Exa search failed") + return ExaSearchResponse(success=False, error=f"Exa search failed: {e!s}") + + +@tool() +async def exa_find_similar( + url: str, + num_results: int = 10, + content_type: Literal["highlights", "text"] = "highlights", + max_characters: int = 4000, + exclude_source_domain: bool = False, +) -> ExaFindSimilarResponse: + """Find web pages similar to a given URL using Exa. + + Args: + url: The URL to find similar pages for. + num_results: Number of similar results to return. + content_type: Content extraction mode — "highlights" for key excerpts, + "text" for full content. + max_characters: Maximum characters of content per result. + exclude_source_domain: Exclude results from the same domain as the source URL. + + Returns: + Pages similar to the input URL with titles, URLs, and content. + """ + try: + api_key = await resolve_credential("EXA_API_KEY") + if not api_key: + return ExaFindSimilarResponse( + success=False, + error="Exa API not configured. Set EXA_API_KEY environment variable.", + ) + + if not url: + return ExaFindSimilarResponse( + success=False, error="Please provide a URL to find similar pages." + ) + + logger.info("Exa find_similar url=%s num_results=%d", url, num_results) + + exa_client = Exa(api_key) + exa_results = exa_client.find_similar( + url, + num_results=num_results, + exclude_source_domain=exclude_source_domain, + contents=_build_contents(content_type, max_characters), + ) + + search_results = [_build_result(r, max_characters) for r in exa_results.results] + + data = ExaFindSimilarData(url=url, results=search_results) + logger.info("Exa find_similar complete results=%d", len(search_results)) + return ExaFindSimilarResponse(success=True, data=data) + except Exception as e: + logger.exception("Exa find_similar failed") + return ExaFindSimilarResponse( + success=False, error=f"Exa find_similar failed: {e!s}" + ) + + +@tool() +async def exa_get_contents( + urls: list[str], + content_type: Literal["highlights", "text"] = "text", + max_characters: int = 20000, +) -> ExaGetContentsResponse: + """Get content from specific URLs using Exa. + + Use this to extract text or highlights from known URLs without searching. + + Args: + urls: List of URLs to extract content from. + content_type: Content extraction mode — "text" for full contiguous content, + "highlights" for key excerpts. + max_characters: Maximum characters of content per URL. + + Returns: + Extracted content for each URL. + """ + try: + api_key = await resolve_credential("EXA_API_KEY") + if not api_key: + return ExaGetContentsResponse( + success=False, + error="Exa API not configured. Set EXA_API_KEY environment variable.", + ) + + if not urls: + return ExaGetContentsResponse( + success=False, error="Please provide at least one URL." + ) + + logger.info("Exa get_contents urls=%d", len(urls)) + + exa_client = Exa(api_key) + content_kwargs: dict = {} + if content_type == "highlights": + content_kwargs["highlights"] = HighlightsContentsOptions( + max_characters=max_characters + ) + else: + content_kwargs["text"] = TextContentsOptions(max_characters=max_characters) + exa_results = exa_client.get_contents(urls, **content_kwargs) + + results = [_build_result(r, max_characters) for r in exa_results.results] + + data = ExaGetContentsData(results=results) + logger.info("Exa get_contents complete results=%d", len(results)) + return ExaGetContentsResponse(success=True, data=data) + except Exception as e: + logger.exception("Exa get_contents failed") + return ExaGetContentsResponse( + success=False, error=f"Exa get_contents failed: {e!s}" + ) + + +@tool() +async def exa_answer( + query: str, +) -> ExaAnswerResponse: + """Ask a question and get an AI-generated answer with cited web sources using Exa. + + Best for factual questions that benefit from real-time web citations. + + Args: + query: The question to answer. + + Returns: + An AI-generated answer with source citations. + """ + try: + api_key = await resolve_credential("EXA_API_KEY") + if not api_key: + return ExaAnswerResponse( + success=False, + error="Exa API not configured. Set EXA_API_KEY environment variable.", + ) + + if not query: + return ExaAnswerResponse( + success=False, error="Please provide a question to answer." + ) + + logger.info("Exa answer query_length=%d", len(query)) + + exa_client = Exa(api_key) + exa_result = exa_client.answer(query, text=True) + + answer_text = getattr(exa_result, "answer", "") or "" + if isinstance(answer_text, dict): + answer_text = str(answer_text) + raw_citations = getattr(exa_result, "citations", []) or [] + citations = [_build_result(r, 4000) for r in raw_citations] + + data = ExaAnswerData( + query=query, + answer=answer_text, + citations=citations, + ) + + logger.info("Exa answer complete citations=%d", len(citations)) + return ExaAnswerResponse(success=True, data=data) + except Exception as e: + logger.exception("Exa answer failed") + return ExaAnswerResponse(success=False, error=f"Exa answer failed: {e!s}") diff --git a/src/tools/search/schemas.py b/src/tools/search/schemas.py new file mode 100644 index 0000000..92cf999 --- /dev/null +++ b/src/tools/search/schemas.py @@ -0,0 +1,457 @@ +"""Pydantic output schemas for search tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Tavily Search Data Schemas +# ============================================================================= + + +class TavilySearchResult(BaseModel): + """A single search result from Tavily.""" + + title: str = Field(..., description="Title of the search result") + url: str = Field(..., description="URL of the search result") + content: str = Field(..., description="Content snippet from the result") + score: float = Field(..., description="Relevance score of the result") + + +class TavilyWebSearchData(BaseModel): + """Output data for tavily_web_search tool.""" + + query: str = Field(..., description="The search query that was executed") + answer: str | None = Field(None, description="AI-generated answer if available") + results: list[TavilySearchResult] = Field(..., description="List of search results") + + +# ============================================================================= +# Shared Search Schemas (used by multiple search engines) +# ============================================================================= + + +class SearchResult(BaseModel): + """A single search result shared across search engines.""" + + title: str = Field(..., description="Title of the search result") + url: str = Field(..., description="URL of the search result") + snippet: str = Field( + ..., description="Content snippet or description from the result" + ) + score: float | None = Field( + default=None, description="Relevance score of the result" + ) + + +class WebSearchData(BaseModel): + """Output data for generic web search tools.""" + + query: str = Field(..., description="The search query that was executed") + results: list[SearchResult] = Field( + default_factory=list, description="List of search results" + ) + total_results: int | None = Field( + None, description="Total number of results available" + ) + + +# ============================================================================= +# DuckDuckGo Schemas +# ============================================================================= + + +class DuckDuckGoNewsResult(BaseModel): + """A single news result from DuckDuckGo.""" + + title: str = Field(..., description="Title of the news article") + url: str = Field(..., description="URL of the news article") + snippet: str = Field(..., description="Snippet or body of the news article") + source: str | None = Field(None, description="News source name") + date: str | None = Field(None, description="Publication date") + + +class DuckDuckGoNewsData(BaseModel): + """Output data for duckduckgo_news tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[DuckDuckGoNewsResult] = Field( + default_factory=list, description="List of news results" + ) + + +# ============================================================================= +# SerpAPI Schemas +# ============================================================================= + + +class SerpApiSearchData(BaseModel): + """Output data for serpapi_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[SearchResult] = Field( + default_factory=list, description="Organic search results" + ) + knowledge_graph: dict | None = Field( + None, description="Knowledge graph data if available" + ) + related_questions: list[dict] | None = Field( + None, description="Related questions if available" + ) + + +# ============================================================================= +# Exa Schemas +# ============================================================================= + + +class ExaSearchResult(BaseModel): + """A single search result from Exa.""" + + title: str = Field(..., description="Title of the search result") + url: str = Field(..., description="URL of the search result") + snippet: str = Field("", description="Text or highlight content from the result") + highlights: list[str] | None = Field( + None, description="Key excerpt highlights from the result" + ) + author: str | None = Field(None, description="Author of the content") + published_date: str | None = Field(None, description="Publication date") + score: float | None = Field(None, description="Relevance score") + + +class ExaSearchData(BaseModel): + """Output data for exa_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[ExaSearchResult] = Field( + default_factory=list, description="List of search results" + ) + autoprompt_string: str | None = Field( + None, description="Auto-generated prompt string used by Exa" + ) + + +# ============================================================================= +# Seltz Schemas +# ============================================================================= + + +class SeltzSearchResult(BaseModel): + """A single search result from Seltz.""" + + url: str | None = Field(None, description="URL of the document") + content: str | None = Field(None, description="Document content") + title: str | None = Field(None, description="Document title") + score: float | None = Field(None, description="Relevance score") + + +class SeltzSearchData(BaseModel): + """Output data for seltz_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[SeltzSearchResult] = Field( + default_factory=list, description="List of search results" + ) + + +# ============================================================================= +# Valyu Schemas +# ============================================================================= + + +class ValyuSearchResult(BaseModel): + """A single search result from Valyu.""" + + title: str | None = Field(None, description="Title of the result") + url: str | None = Field(None, description="URL of the result") + snippet: str | None = Field(None, description="Content snippet from the result") + source: str | None = Field(None, description="Source name") + relevance_score: float | None = Field(None, description="Relevance score") + + +class ValyuSearchData(BaseModel): + """Output data for valyu_search tool.""" + + query: str = Field(..., description="The search query that was executed") + search_type: str = Field(..., description="Type of search performed") + results: list[ValyuSearchResult] = Field( + default_factory=list, description="List of search results" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class TavilyWebSearchResponse(ToolResponse[TavilyWebSearchData]): + """Response schema for tavily_web_search tool.""" + + pass + + +class WebSearchResponse(ToolResponse[WebSearchData]): + """Response schema for generic web search tools (DuckDuckGo, Brave, Serper, SearXNG, Baidu).""" + + pass + + +class DuckDuckGoNewsResponse(ToolResponse[DuckDuckGoNewsData]): + """Response schema for duckduckgo_news tool.""" + + pass + + +class SerpApiSearchResponse(ToolResponse[SerpApiSearchData]): + """Response schema for serpapi_search tool.""" + + pass + + +class ExaSearchResponse(ToolResponse[ExaSearchData]): + """Response schema for exa_search tool.""" + + pass + + +class SeltzSearchResponse(ToolResponse[SeltzSearchData]): + """Response schema for seltz_search tool.""" + + pass + + +class ValyuSearchResponse(ToolResponse[ValyuSearchData]): + """Response schema for valyu_search tool.""" + + pass + + +# ============================================================================= +# Tavily Extract Schemas +# ============================================================================= + + +class TavilyExtractResult(BaseModel): + """A single extract result from Tavily.""" + + url: str = Field(..., description="URL that was extracted") + raw_content: str = Field(..., description="Extracted text content from the URL") + + +class TavilyExtractData(BaseModel): + """Output data for tavily_extract tool.""" + + results: list[TavilyExtractResult] = Field( + default_factory=list, description="List of extraction results" + ) + failed_results: list[dict] | None = Field( + None, description="URLs that failed to extract" + ) + + +class TavilyExtractResponse(ToolResponse[TavilyExtractData]): + """Response schema for tavily_extract tool.""" + + pass + + +# ============================================================================= +# Brave News/Image Schemas +# ============================================================================= + + +class BraveNewsResult(BaseModel): + """A single news result from Brave Search.""" + + title: str = Field(..., description="Title of the news article") + url: str = Field(..., description="URL of the news article") + description: str = Field("", description="Description snippet") + age: str | None = Field( + None, description="Age of the article (e.g., '2 hours ago')" + ) + source: str | None = Field(None, description="News source name") + + +class BraveNewsData(BaseModel): + """Output data for brave_news_search tool.""" + + query: str = Field(..., description="The search query") + results: list[BraveNewsResult] = Field( + default_factory=list, description="List of news results" + ) + + +class BraveNewsResponse(ToolResponse[BraveNewsData]): + """Response schema for brave_news_search tool.""" + + pass + + +class BraveImageResult(BaseModel): + """A single image result from Brave Search.""" + + title: str = Field(..., description="Title of the image") + url: str = Field(..., description="Page URL containing the image") + thumbnail_url: str = Field("", description="Thumbnail image URL") + source: str | None = Field(None, description="Source domain") + + +class BraveImagesData(BaseModel): + """Output data for brave_image_search tool.""" + + query: str = Field(..., description="The search query") + results: list[BraveImageResult] = Field( + default_factory=list, description="List of image results" + ) + + +class BraveImagesResponse(ToolResponse[BraveImagesData]): + """Response schema for brave_image_search tool.""" + + pass + + +# ============================================================================= +# DuckDuckGo Image Schemas +# ============================================================================= + + +class DuckDuckGoImageResult(BaseModel): + """A single image result from DuckDuckGo.""" + + title: str = Field(..., description="Title of the image") + url: str = Field(..., description="Page URL containing the image") + image_url: str = Field(..., description="Direct URL to the image") + thumbnail_url: str = Field("", description="Thumbnail URL") + source: str | None = Field(None, description="Source domain") + width: int | None = Field(None, description="Image width in pixels") + height: int | None = Field(None, description="Image height in pixels") + + +class DuckDuckGoImagesData(BaseModel): + """Output data for duckduckgo_images tool.""" + + query: str = Field(..., description="The search query") + results: list[DuckDuckGoImageResult] = Field( + default_factory=list, description="List of image results" + ) + + +class DuckDuckGoImagesResponse(ToolResponse[DuckDuckGoImagesData]): + """Response schema for duckduckgo_images tool.""" + + pass + + +# ============================================================================= +# Exa Find Similar Schemas +# ============================================================================= + + +class ExaFindSimilarData(BaseModel): + """Output data for exa_find_similar tool.""" + + url: str = Field(..., description="The source URL used to find similar pages") + results: list[ExaSearchResult] = Field( + default_factory=list, description="List of similar results" + ) + + +class ExaFindSimilarResponse(ToolResponse[ExaFindSimilarData]): + """Response schema for exa_find_similar tool.""" + + pass + + +# ============================================================================= +# Exa Get Contents Schemas +# ============================================================================= + + +class ExaGetContentsData(BaseModel): + """Output data for exa_get_contents tool.""" + + results: list[ExaSearchResult] = Field( + default_factory=list, description="Content extracted from the provided URLs" + ) + + +class ExaGetContentsResponse(ToolResponse[ExaGetContentsData]): + """Response schema for exa_get_contents tool.""" + + pass + + +# ============================================================================= +# Exa Answer Schemas +# ============================================================================= + + +class ExaAnswerData(BaseModel): + """Output data for exa_answer tool.""" + + query: str = Field(..., description="The question that was answered") + answer: str = Field(..., description="AI-generated answer with citations") + citations: list[ExaSearchResult] = Field( + default_factory=list, description="Source citations for the answer" + ) + + +class ExaAnswerResponse(ToolResponse[ExaAnswerData]): + """Response schema for exa_answer tool.""" + + pass + + +# ============================================================================= +# Serper News/Image Schemas +# ============================================================================= + + +class SerperNewsResult(BaseModel): + """A single news result from Serper.""" + + title: str = Field(..., description="Title of the news article") + link: str = Field(..., description="URL of the news article") + snippet: str = Field("", description="Description snippet") + date: str | None = Field(None, description="Publication date") + source: str | None = Field(None, description="News source name") + + +class SerperNewsData(BaseModel): + """Output data for serper_news_search tool.""" + + query: str = Field(..., description="The search query") + results: list[SerperNewsResult] = Field( + default_factory=list, description="List of news results" + ) + + +class SerperNewsResponse(ToolResponse[SerperNewsData]): + """Response schema for serper_news_search tool.""" + + pass + + +class SerperImageResult(BaseModel): + """A single image result from Serper.""" + + title: str = Field(..., description="Title of the image") + link: str = Field(..., description="Page URL") + image_url: str = Field(..., description="Direct image URL") + source: str | None = Field(None, description="Source domain") + + +class SerperImagesData(BaseModel): + """Output data for serper_image_search tool.""" + + query: str = Field(..., description="The search query") + results: list[SerperImageResult] = Field( + default_factory=list, description="List of image results" + ) + + +class SerperImagesResponse(ToolResponse[SerperImagesData]): + """Response schema for serper_image_search tool.""" + + pass diff --git a/src/tools/search/searxng.py b/src/tools/search/searxng.py new file mode 100644 index 0000000..cdefe04 --- /dev/null +++ b/src/tools/search/searxng.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging +import os +import urllib.parse + +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + SearchResult, + WebSearchData, + WebSearchResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for SearXNG tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.searxng") + + +@tool() +async def searxng_search( + query: str, + categories: str | None = None, + num_results: int = 5, + engines: str | None = None, +) -> WebSearchResponse: + """Search the web using a self-hosted SearXNG instance. + + Args: + query: The search query to look up. + categories: Search category (e.g., "general", "images", "news", "it", "science", "videos", "music", "map"). + num_results: Maximum number of results to return. + engines: Comma-separated list of specific engines to use (e.g., "google,duckduckgo"). + + Returns: + Search results with titles, URLs, and snippets. + """ + try: + base_url = os.getenv("SEARXNG_BASE_URL") + if not base_url: + return WebSearchResponse( + success=False, + error="SearXNG not configured. Set SEARXNG_BASE_URL environment variable.", + ) + + if not query: + return WebSearchResponse( + success=False, error="Please provide a query to search for." + ) + + if num_results < 1: + return WebSearchResponse( + success=False, error="num_results must be at least 1" + ) + + logger.info( + "SearXNG search query_length=%d categories=%s num_results=%d", + len(query), + categories, + num_results, + ) + + encoded_query = urllib.parse.quote(query) + url = f"{base_url.rstrip('/')}/search?format=json&q={encoded_query}" + + if categories: + url += f"&categories={urllib.parse.quote(categories)}" + if engines: + url += f"&engines={urllib.parse.quote(engines)}" + + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=30.0) + response.raise_for_status() + response_data = response.json() + + raw_results = response_data.get("results", [])[:num_results] + + search_results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("url", ""), + snippet=r.get("content", ""), + score=r.get("score"), + ) + for r in raw_results + ] + + data = WebSearchData( + query=query, + results=search_results, + total_results=response_data.get("number_of_results"), + ) + + logger.info("SearXNG search complete results=%d", len(search_results)) + return WebSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("SearXNG search failed") + return WebSearchResponse( + success=False, error=f"SearXNG search failed: {str(e)}" + ) diff --git a/src/tools/search/seltz.py b/src/tools/search/seltz.py new file mode 100644 index 0000000..e96a472 --- /dev/null +++ b/src/tools/search/seltz.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + SeltzSearchData, + SeltzSearchResponse, + SeltzSearchResult, +) + +try: + from seltz import Includes, Seltz +except ImportError as err: + raise ImportError( + "seltz is required for Seltz search tools. Install with: pip install seltz" + ) from err + +logger = logging.getLogger("humcp.tools.seltz") + + +def _parse_document(doc: Any) -> dict: + """Parse a Seltz document into a dictionary.""" + if hasattr(doc, "to_dict"): + return doc.to_dict() + doc_dict: dict[str, Any] = {} + url = getattr(doc, "url", None) + content = getattr(doc, "content", None) + title = getattr(doc, "title", None) + score = getattr(doc, "score", None) + if url is not None: + doc_dict["url"] = url + if content: + doc_dict["content"] = content + if title: + doc_dict["title"] = title + if score is not None: + doc_dict["score"] = score + return doc_dict + + +@tool() +async def seltz_search( + query: str, + max_results: int = 10, + context: str | None = None, +) -> SeltzSearchResponse: + """Search using the Seltz AI-powered search API. + + Args: + query: The search query to look up. + max_results: Maximum number of documents to return. + context: Additional context to improve search quality (e.g., "user is looking for Python docs"). + + Returns: + Search results with URLs, content, and metadata. + """ + try: + api_key = await resolve_credential("SELTZ_API_KEY") + if not api_key: + return SeltzSearchResponse( + success=False, + error="Seltz API not configured. Set SELTZ_API_KEY environment variable.", + ) + + if not query: + return SeltzSearchResponse( + success=False, error="Please provide a query to search for." + ) + + if max_results < 1: + return SeltzSearchResponse( + success=False, error="max_results must be at least 1" + ) + + logger.info( + "Seltz search query_length=%d max_results=%d", len(query), max_results + ) + + client = Seltz(api_key=api_key) + includes = Includes(max_documents=max_results) + response = client.search( + query=query, + includes=includes, + context=context, + ) + + search_results = [] + for doc in response.documents or []: + parsed = _parse_document(doc) + if parsed: + search_results.append( + SeltzSearchResult( + url=parsed.get("url"), + content=parsed.get("content"), + title=parsed.get("title"), + score=parsed.get("score"), + ) + ) + + data = SeltzSearchData(query=query, results=search_results) + + logger.info("Seltz search complete results=%d", len(search_results)) + return SeltzSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Seltz search failed") + return SeltzSearchResponse( + success=False, error=f"Seltz search failed: {str(e)}" + ) diff --git a/src/tools/search/serpapi.py b/src/tools/search/serpapi.py new file mode 100644 index 0000000..c28cfe1 --- /dev/null +++ b/src/tools/search/serpapi.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + SearchResult, + SerpApiSearchData, + SerpApiSearchResponse, +) + +try: + import serpapi as serpapi_lib +except ImportError as err: + raise ImportError( + "google-search-results is required for SerpAPI tools. " + "Install with: pip install google-search-results" + ) from err + +logger = logging.getLogger("humcp.tools.serpapi") + + +@tool() +async def serpapi_search( + query: str, + engine: str = "google", + num_results: int = 10, +) -> SerpApiSearchResponse: + """Search the web using SerpAPI (supports Google, Bing, Yahoo, and more). + + Args: + query: The search query to look up. + engine: Search engine to use (e.g., "google", "bing", "yahoo"). Defaults to "google". + num_results: Number of results to return. + + Returns: + Search results with titles, URLs, snippets, knowledge graph, and related questions. + """ + try: + api_key = await resolve_credential("SERPAPI_API_KEY") + if not api_key: + return SerpApiSearchResponse( + success=False, + error="SerpAPI not configured. Set SERPAPI_API_KEY environment variable.", + ) + + if not query: + return SerpApiSearchResponse( + success=False, error="Please provide a query to search for." + ) + + logger.info( + "SerpAPI search query_length=%d engine=%s num_results=%d", + len(query), + engine, + num_results, + ) + + params = { + "q": query, + "api_key": api_key, + "num": num_results, + } + + search = serpapi_lib.GoogleSearch(params) + results = search.get_dict() + + organic = results.get("organic_results", []) + search_results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("link", ""), + snippet=r.get("snippet", ""), + score=r.get("position"), + ) + for r in organic + ] + + knowledge_graph = results.get("knowledge_graph") + related_questions = results.get("related_questions") + + data = SerpApiSearchData( + query=query, + results=search_results, + knowledge_graph=knowledge_graph, + related_questions=related_questions, + ) + + logger.info("SerpAPI search complete results=%d", len(search_results)) + return SerpApiSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("SerpAPI search failed") + return SerpApiSearchResponse( + success=False, error=f"SerpAPI search failed: {str(e)}" + ) diff --git a/src/tools/search/serper.py b/src/tools/search/serper.py new file mode 100644 index 0000000..a98b4f9 --- /dev/null +++ b/src/tools/search/serper.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + SearchResult, + SerperImageResult, + SerperImagesData, + SerperImagesResponse, + SerperNewsData, + SerperNewsResponse, + SerperNewsResult, + WebSearchData, + WebSearchResponse, +) + +try: + import httpx +except ImportError as err: + raise ImportError( + "httpx is required for Serper tools. Install with: pip install httpx" + ) from err + +logger = logging.getLogger("humcp.tools.serper") + +SERPER_SEARCH_URL = "https://google.serper.dev/search" +SERPER_NEWS_URL = "https://google.serper.dev/news" +SERPER_IMAGES_URL = "https://google.serper.dev/images" + + +@tool() +async def serper_google_search( + query: str, + num_results: int = 10, + location: str = "us", + language: str = "en", +) -> WebSearchResponse: + """Search Google using the Serper.dev API. + + Args: + query: The search query to look up. + num_results: Number of results to return. + location: Google location code for search results (e.g., "us", "uk"). + language: Language code for search results (e.g., "en", "fr"). + + Returns: + Search results with titles, URLs, and snippets. + """ + try: + api_key = await resolve_credential("SERPER_API_KEY") + if not api_key: + return WebSearchResponse( + success=False, + error="Serper API not configured. Set SERPER_API_KEY environment variable.", + ) + + if not query: + return WebSearchResponse( + success=False, error="Please provide a query to search for." + ) + + logger.info( + "Serper search query_length=%d num_results=%d", len(query), num_results + ) + + headers = { + "X-API-KEY": api_key, + "Content-Type": "application/json", + } + payload = { + "q": query, + "num": num_results, + "gl": location, + "hl": language, + } + + async with httpx.AsyncClient() as client: + response = await client.post( + SERPER_SEARCH_URL, headers=headers, json=payload, timeout=30.0 + ) + response.raise_for_status() + response_data = response.json() + + organic = response_data.get("organic", []) + search_results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("link", ""), + snippet=r.get("snippet", ""), + score=r.get("position"), + ) + for r in organic + ] + + data = WebSearchData( + query=query, + results=search_results, + total_results=len(search_results), + ) + + logger.info("Serper search complete results=%d", len(search_results)) + return WebSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Serper search failed") + return WebSearchResponse(success=False, error=f"Serper search failed: {str(e)}") + + +@tool() +async def serper_news_search( + query: str, + num_results: int = 10, + location: str = "us", + language: str = "en", +) -> SerperNewsResponse: + """Search for news articles using the Serper.dev News API. + + Args: + query: The news search query. + num_results: Number of results to return. + location: Country code for geolocation (e.g., "us", "uk"). + language: Language code (e.g., "en", "fr"). + + Returns: + News results with titles, URLs, snippets, dates, and sources. + """ + try: + api_key = await resolve_credential("SERPER_API_KEY") + if not api_key: + return SerperNewsResponse( + success=False, + error="Serper API not configured. Set SERPER_API_KEY environment variable.", + ) + + headers = {"X-API-KEY": api_key, "Content-Type": "application/json"} + payload = {"q": query, "num": num_results, "gl": location, "hl": language} + + async with httpx.AsyncClient() as client: + response = await client.post( + SERPER_NEWS_URL, headers=headers, json=payload, timeout=30.0 + ) + response.raise_for_status() + response_data = response.json() + + news_results = [ + SerperNewsResult( + title=r.get("title", ""), + link=r.get("link", ""), + snippet=r.get("snippet", ""), + date=r.get("date"), + source=r.get("source"), + ) + for r in response_data.get("news", []) + ] + + data = SerperNewsData(query=query, results=news_results) + return SerperNewsResponse(success=True, data=data) + except Exception as e: + logger.exception("Serper news search failed") + return SerperNewsResponse( + success=False, error=f"Serper news search failed: {str(e)}" + ) + + +@tool() +async def serper_image_search( + query: str, + num_results: int = 10, + location: str = "us", + language: str = "en", +) -> SerperImagesResponse: + """Search for images using the Serper.dev Images API. + + Args: + query: The image search query. + num_results: Number of results to return. + location: Country code for geolocation (e.g., "us", "uk"). + language: Language code (e.g., "en", "fr"). + + Returns: + Image results with titles, page URLs, and image URLs. + """ + try: + api_key = await resolve_credential("SERPER_API_KEY") + if not api_key: + return SerperImagesResponse( + success=False, + error="Serper API not configured. Set SERPER_API_KEY environment variable.", + ) + + headers = {"X-API-KEY": api_key, "Content-Type": "application/json"} + payload = {"q": query, "num": num_results, "gl": location, "hl": language} + + async with httpx.AsyncClient() as client: + response = await client.post( + SERPER_IMAGES_URL, headers=headers, json=payload, timeout=30.0 + ) + response.raise_for_status() + response_data = response.json() + + image_results = [ + SerperImageResult( + title=r.get("title", ""), + link=r.get("link", ""), + image_url=r.get("imageUrl", ""), + source=r.get("source"), + ) + for r in response_data.get("images", []) + ] + + data = SerperImagesData(query=query, results=image_results) + return SerperImagesResponse(success=True, data=data) + except Exception as e: + logger.exception("Serper image search failed") + return SerperImagesResponse( + success=False, error=f"Serper image search failed: {str(e)}" + ) diff --git a/src/tools/search/valyu.py b/src/tools/search/valyu.py new file mode 100644 index 0000000..3e6c488 --- /dev/null +++ b/src/tools/search/valyu.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.search.schemas import ( + ValyuSearchData, + ValyuSearchResponse, + ValyuSearchResult, +) + +try: + from valyu import Valyu +except ImportError as err: + raise ImportError( + "valyu is required for Valyu search tools. Install with: pip install valyu" + ) from err + +logger = logging.getLogger("humcp.tools.valyu") + + +@tool() +async def valyu_search( + query: str, + search_type: str = "web", + max_results: int = 10, + max_price: float = 30.0, + content_category: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> ValyuSearchResponse: + """Search using the Valyu API for web and academic content. + + Args: + query: The search query to look up. + search_type: Type of search: "web" for web sources, "proprietary" for academic/proprietary sources. + max_results: Maximum number of results to return. + max_price: Maximum price for the API call. + content_category: Description of the category of the query for filtering. + start_date: Filter content after this date (YYYY-MM-DD). + end_date: Filter content before this date (YYYY-MM-DD). + + Returns: + Search results with titles, URLs, snippets, sources, and relevance scores. + """ + try: + api_key = await resolve_credential("VALYU_API_KEY") + if not api_key: + return ValyuSearchResponse( + success=False, + error="Valyu API not configured. Set VALYU_API_KEY environment variable.", + ) + + if not query: + return ValyuSearchResponse( + success=False, error="Please provide a query to search for." + ) + + if max_results < 1: + return ValyuSearchResponse( + success=False, error="max_results must be at least 1" + ) + + logger.info( + "Valyu search query_length=%d search_type=%s max_results=%d", + len(query), + search_type, + max_results, + ) + + valyu_client = Valyu(api_key=api_key) + + search_params: dict = { + "query": query, + "search_type": search_type, + "max_num_results": max_results, + "max_price": max_price, + } + if content_category: + search_params["category"] = content_category + if start_date: + search_params["start_date"] = start_date + if end_date: + search_params["end_date"] = end_date + + response = valyu_client.search(**search_params) + + if not response.success: + error_msg = getattr(response, "error", None) or "Search request failed" + return ValyuSearchResponse(success=False, error=str(error_msg)) + + search_results = [] + for r in response.results or []: + content = getattr(r, "content", "") or "" + description = getattr(r, "description", "") or "" + snippet = content[:1000] if content else description + + search_results.append( + ValyuSearchResult( + title=getattr(r, "title", None), + url=getattr(r, "url", None), + snippet=snippet or None, + source=getattr(r, "source", None), + relevance_score=getattr(r, "relevance_score", None), + ) + ) + + data = ValyuSearchData( + query=query, + search_type=search_type, + results=search_results, + ) + + logger.info("Valyu search complete results=%d", len(search_results)) + return ValyuSearchResponse(success=True, data=data) + except Exception as e: + logger.exception("Valyu search failed") + return ValyuSearchResponse( + success=False, error=f"Valyu search failed: {str(e)}" + ) diff --git a/src/tools/social/SKILL.md b/src/tools/social/SKILL.md new file mode 100644 index 0000000..0cf8d17 --- /dev/null +++ b/src/tools/social/SKILL.md @@ -0,0 +1,72 @@ +--- +name: social-media +description: Interact with X (Twitter) for searching tweets, posting tweets, and fetching user profiles and timelines. +--- + +# Social Media Tools + +Tools for interacting with X (Twitter). + +## Requirements + +Set environment variables depending on which tools you need: + +- **X / Twitter**: `X_BEARER_TOKEN` (read), `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET` (write) + +## X / Twitter + +### Post a tweet + +```python +result = await x_post_tweet(text="Hello from HuMCP!") +``` + +### Search tweets + +```python +result = await x_search_tweets( + query="OpenAI", + max_results=10 +) +``` + +### Get user info + +```python +result = await x_get_user(username="elonmusk") +``` + +### Get user tweets + +```python +result = await x_get_user_tweets( + username="elonmusk", + max_results=10 +) +``` + +## Response Format + +All tools return a consistent response: + +```json +{ + "success": true, + "data": { ... } +} +``` + +On failure: + +```json +{ + "success": false, + "error": "Description of what went wrong" +} +``` + +## When to Use + +- Monitoring X/Twitter for mentions or trending topics +- Posting tweets from automated workflows +- Fetching user profiles and recent timelines diff --git a/src/tools/social/__init__.py b/src/tools/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/social/hackernews.py b/src/tools/social/hackernews.py new file mode 100644 index 0000000..d7ee085 --- /dev/null +++ b/src/tools/social/hackernews.py @@ -0,0 +1,374 @@ +"""Hacker News tools for browsing top stories and searching via Algolia.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.decorator import tool +from src.tools.social.schemas import ( + HackerNewsComment, + HackerNewsCommentsData, + HackerNewsCommentsResponse, + HackerNewsSearchByDateResponse, + HackerNewsSearchData, + HackerNewsSearchResponse, + HackerNewsSearchResult, + HackerNewsStory, + HackerNewsStoryData, + HackerNewsStoryResponse, + HackerNewsTopStoriesData, + HackerNewsTopStoriesResponse, + HackerNewsUser, + HackerNewsUserData, + HackerNewsUserResponse, +) + +logger = logging.getLogger("humcp.tools.hackernews") + +_HN_API_BASE = "https://hacker-news.firebaseio.com/v0" +_ALGOLIA_API_BASE = "https://hn.algolia.com/api/v1" + + +@tool() +async def hackernews_get_top_stories( + limit: int = 10, +) -> HackerNewsTopStoriesResponse: + """Get the current top stories from Hacker News. + + Args: + limit: Maximum number of stories to return (default 10, max 50). + + Returns: + Top stories or an error message. + """ + try: + limit = max(1, min(limit, 50)) + logger.info("HN get top stories limit=%d", limit) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{_HN_API_BASE}/topstories.json") + response.raise_for_status() + story_ids: list[int] = response.json() + + stories: list[HackerNewsStory] = [] + for story_id in story_ids[:limit]: + item_resp = await client.get(f"{_HN_API_BASE}/item/{story_id}.json") + item_resp.raise_for_status() + item = item_resp.json() + if item is None: + continue + + stories.append( + HackerNewsStory( + id=item["id"], + title=item.get("title", ""), + url=item.get("url"), + score=item.get("score", 0), + by=item.get("by", "unknown"), + time=item.get("time", 0), + descendants=item.get("descendants", 0), + type=item.get("type", "story"), + ) + ) + + logger.info("HN top stories complete results=%d", len(stories)) + return HackerNewsTopStoriesResponse( + success=True, + data=HackerNewsTopStoriesData(stories=stories), + ) + except Exception as e: + logger.exception("HN get top stories failed") + return HackerNewsTopStoriesResponse( + success=False, error=f"Hacker News top stories failed: {e}" + ) + + +@tool() +async def hackernews_get_story(story_id: int) -> HackerNewsStoryResponse: + """Get a specific Hacker News story by its ID. + + Args: + story_id: The numeric Hacker News item ID. + + Returns: + The story details or an error message. + """ + try: + logger.info("HN get story id=%d", story_id) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{_HN_API_BASE}/item/{story_id}.json") + response.raise_for_status() + item = response.json() + + if item is None: + return HackerNewsStoryResponse( + success=False, + error=f"Story with ID {story_id} not found.", + ) + + story = HackerNewsStory( + id=item["id"], + title=item.get("title", ""), + url=item.get("url"), + score=item.get("score", 0), + by=item.get("by", "unknown"), + time=item.get("time", 0), + descendants=item.get("descendants", 0), + type=item.get("type", "story"), + ) + + return HackerNewsStoryResponse( + success=True, + data=HackerNewsStoryData(story=story), + ) + except Exception as e: + logger.exception("HN get story failed") + return HackerNewsStoryResponse( + success=False, error=f"Hacker News get story failed: {e}" + ) + + +@tool() +async def hackernews_search( + query: str, + limit: int = 10, +) -> HackerNewsSearchResponse: + """Search Hacker News stories using the Algolia HN Search API. + + Args: + query: The search query string. + limit: Maximum number of results to return (default 10, max 50). + + Returns: + Matching stories or an error message. + """ + try: + limit = max(1, min(limit, 50)) + logger.info("HN search query=%r limit=%d", query, limit) + + params = { + "query": query, + "tags": "story", + "hitsPerPage": limit, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_ALGOLIA_API_BASE}/search", + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + data = response.json() + + results: list[HackerNewsSearchResult] = [] + for hit in data.get("hits", []): + results.append( + HackerNewsSearchResult( + objectID=hit.get("objectID", ""), + title=hit.get("title", ""), + url=hit.get("url"), + points=hit.get("points"), + author=hit.get("author"), + created_at=hit.get("created_at"), + num_comments=hit.get("num_comments"), + ) + ) + + logger.info("HN search complete results=%d", len(results)) + return HackerNewsSearchResponse( + success=True, + data=HackerNewsSearchData( + query=query, + results=results, + ), + ) + except Exception as e: + logger.exception("HN search failed") + return HackerNewsSearchResponse( + success=False, error=f"Hacker News search failed: {e}" + ) + + +@tool() +async def hackernews_search_by_date( + query: str, + tags: str = "story", + limit: int = 10, +) -> HackerNewsSearchByDateResponse: + """Search Hacker News stories sorted by date (most recent first) using the Algolia API. + + Unlike hackernews_search which sorts by relevance, this returns results + ordered by creation date, making it useful for finding the latest discussions. + + Args: + query: The search query string. + tags: Filter by item type. Options: "story", "comment", "show_hn", "ask_hn", "front_page". Can combine with comma: "story,show_hn". Default is "story". + limit: Maximum number of results to return (default 10, max 50). + + Returns: + Matching items sorted by date (newest first) or an error message. + """ + try: + limit = max(1, min(limit, 50)) + logger.info("HN search_by_date query=%r tags=%s limit=%d", query, tags, limit) + + params = { + "query": query, + "tags": tags, + "hitsPerPage": limit, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_ALGOLIA_API_BASE}/search_by_date", + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + data = response.json() + + results: list[HackerNewsSearchResult] = [] + for hit in data.get("hits", []): + results.append( + HackerNewsSearchResult( + objectID=hit.get("objectID", ""), + title=hit.get("title", ""), + url=hit.get("url"), + points=hit.get("points"), + author=hit.get("author"), + created_at=hit.get("created_at"), + num_comments=hit.get("num_comments"), + ) + ) + + logger.info("HN search_by_date complete results=%d", len(results)) + return HackerNewsSearchByDateResponse( + success=True, + data=HackerNewsSearchData( + query=query, + results=results, + ), + ) + except Exception as e: + logger.exception("HN search_by_date failed") + return HackerNewsSearchByDateResponse( + success=False, error=f"Hacker News search by date failed: {e}" + ) + + +@tool() +async def hackernews_get_user(username: str) -> HackerNewsUserResponse: + """Get a Hacker News user profile by username. + + Returns the user's karma, bio, account creation date, and recent submission IDs. + + Args: + username: The Hacker News username (case-sensitive). + + Returns: + User profile details or an error message. + """ + try: + logger.info("HN get user username=%s", username) + + async with httpx.AsyncClient() as client: + response = await client.get(f"{_HN_API_BASE}/user/{username}.json") + response.raise_for_status() + item = response.json() + + if item is None: + return HackerNewsUserResponse( + success=False, + error=f"User '{username}' not found.", + ) + + user = HackerNewsUser( + id=item.get("id", username), + created=item.get("created", 0), + karma=item.get("karma", 0), + about=item.get("about"), + submitted=item.get("submitted", [])[:100], + ) + + return HackerNewsUserResponse( + success=True, + data=HackerNewsUserData(user=user), + ) + except Exception as e: + logger.exception("HN get user failed") + return HackerNewsUserResponse( + success=False, error=f"Hacker News get user failed: {e}" + ) + + +@tool() +async def hackernews_get_comments( + story_id: int, + limit: int = 20, +) -> HackerNewsCommentsResponse: + """Get top-level comments for a Hacker News story. + + Fetches the direct child comments of a story. Does not recurse into + nested replies. + + Args: + story_id: The numeric Hacker News story ID. + limit: Maximum number of comments to return (default 20, max 50). + + Returns: + List of top-level comments or an error message. + """ + try: + limit = max(1, min(limit, 50)) + logger.info("HN get comments story_id=%d limit=%d", story_id, limit) + + async with httpx.AsyncClient() as client: + story_resp = await client.get(f"{_HN_API_BASE}/item/{story_id}.json") + story_resp.raise_for_status() + story = story_resp.json() + + if story is None: + return HackerNewsCommentsResponse( + success=False, + error=f"Story with ID {story_id} not found.", + ) + + kid_ids = story.get("kids", [])[:limit] + comments: list[HackerNewsComment] = [] + + async with httpx.AsyncClient() as client: + for kid_id in kid_ids: + try: + resp = await client.get(f"{_HN_API_BASE}/item/{kid_id}.json") + resp.raise_for_status() + item = resp.json() + if item is None or item.get("deleted") or item.get("dead"): + continue + comments.append( + HackerNewsComment( + id=item["id"], + by=item.get("by"), + text=item.get("text"), + time=item.get("time", 0), + parent=item.get("parent", 0), + kids=item.get("kids", []), + ) + ) + except Exception as e: + logger.warning("Skipping comment %d: %s", kid_id, e) + + logger.info("HN get comments complete results=%d", len(comments)) + return HackerNewsCommentsResponse( + success=True, + data=HackerNewsCommentsData( + story_id=story_id, + comments=comments, + ), + ) + except Exception as e: + logger.exception("HN get comments failed") + return HackerNewsCommentsResponse( + success=False, error=f"Hacker News get comments failed: {e}" + ) diff --git a/src/tools/social/reddit.py b/src/tools/social/reddit.py new file mode 100644 index 0000000..db5f9ab --- /dev/null +++ b/src/tools/social/reddit.py @@ -0,0 +1,413 @@ +"""Reddit tools for searching and browsing posts via PRAW.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.social.schemas import ( + RedditComment, + RedditCommentsData, + RedditCommentsResponse, + RedditHotPostsData, + RedditHotPostsResponse, + RedditPost, + RedditPostData, + RedditPostResponse, + RedditSearchData, + RedditSearchResponse, + RedditSubredditInfo, + RedditSubredditInfoData, + RedditSubredditInfoResponse, + RedditTopPostsData, + RedditTopPostsResponse, +) + +try: + import praw # type: ignore +except ImportError as err: + raise ImportError( + "praw is required for Reddit tools. Install with: pip install praw" + ) from err + +logger = logging.getLogger("humcp.tools.reddit") + + +def _get_reddit_client( + client_id: str | None, + client_secret: str | None, + user_agent: str | None, +) -> praw.Reddit | None: + """Create a read-only Reddit client from resolved credentials.""" + if not client_id or not client_secret: + return None + + return praw.Reddit( + client_id=client_id, + client_secret=client_secret, + user_agent=user_agent or "HuMCP Reddit Tools v1.0", + ) + + +def _submission_to_post(submission: Any) -> RedditPost: + """Convert a PRAW submission object to a RedditPost model.""" + return RedditPost( + id=submission.id, + title=submission.title, + score=submission.score, + url=submission.url, + selftext=submission.selftext or "", + author=str(submission.author) if submission.author else "[deleted]", + permalink=f"https://reddit.com{submission.permalink}", + created_utc=submission.created_utc, + subreddit=str(submission.subreddit), + num_comments=submission.num_comments, + ) + + +@tool() +async def reddit_search_posts( + query: str, + subreddit: str | None = None, + sort: str = "relevance", + time_filter: str = "all", + limit: int = 10, +) -> RedditSearchResponse: + """Search Reddit posts by query, optionally within a specific subreddit. + + Args: + query: The search query string. + subreddit: Optional subreddit to search within (e.g. "python"). Searches all of Reddit if omitted. + sort: Sort order for results. One of "relevance", "hot", "top", "new", "comments". Default is "relevance". + time_filter: Time period filter for results. One of "all", "day", "hour", "month", "week", "year". Default is "all". + limit: Maximum number of results to return (default 10, max 100). + + Returns: + Matching posts or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditSearchResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + valid_sorts = {"relevance", "hot", "top", "new", "comments"} + if sort not in valid_sorts: + return RedditSearchResponse( + success=False, + error=f"Invalid sort '{sort}'. Must be one of: {', '.join(sorted(valid_sorts))}", + ) + + valid_time_filters = {"all", "day", "hour", "month", "week", "year"} + if time_filter not in valid_time_filters: + return RedditSearchResponse( + success=False, + error=f"Invalid time_filter '{time_filter}'. Must be one of: {', '.join(sorted(valid_time_filters))}", + ) + + limit = max(1, min(limit, 100)) + logger.info( + "Reddit search query=%r subreddit=%s sort=%s limit=%d", + query, + subreddit, + sort, + limit, + ) + + if subreddit: + target = reddit.subreddit(subreddit) + else: + target = reddit.subreddit("all") + + submissions = target.search( + query, + sort=sort, + time_filter=time_filter, + limit=limit, + ) + posts = [_submission_to_post(s) for s in submissions] + + logger.info("Reddit search complete results=%d", len(posts)) + return RedditSearchResponse( + success=True, + data=RedditSearchData( + query=query, + subreddit=subreddit, + results=posts, + ), + ) + except Exception as e: + logger.exception("Reddit search failed") + return RedditSearchResponse(success=False, error=f"Reddit search failed: {e}") + + +@tool() +async def reddit_get_post(post_id: str) -> RedditPostResponse: + """Get a specific Reddit post by its ID. + + Args: + post_id: The Reddit post ID (e.g. "1abc23d"). + + Returns: + The post details or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditPostResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + logger.info("Reddit get post id=%s", post_id) + submission = reddit.submission(id=post_id) + # Force-load attributes + _ = submission.title + + post = _submission_to_post(submission) + return RedditPostResponse( + success=True, + data=RedditPostData(post=post), + ) + except Exception as e: + logger.exception("Reddit get post failed") + return RedditPostResponse(success=False, error=f"Reddit get post failed: {e}") + + +@tool() +async def reddit_get_top_posts( + subreddit: str, + time_filter: str = "week", + limit: int = 10, +) -> RedditTopPostsResponse: + """Get the top posts from a subreddit for a given time period. + + Args: + subreddit: Name of the subreddit (e.g. "python"). + time_filter: Time period filter. One of "hour", "day", "week", "month", "year", "all". Default is "week". + limit: Maximum number of posts to return (default 10, max 100). + + Returns: + Top posts or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditTopPostsResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + valid_filters = {"hour", "day", "week", "month", "year", "all"} + if time_filter not in valid_filters: + return RedditTopPostsResponse( + success=False, + error=f"Invalid time_filter '{time_filter}'. Must be one of: {', '.join(sorted(valid_filters))}", + ) + + limit = max(1, min(limit, 100)) + logger.info( + "Reddit top posts subreddit=%s time_filter=%s limit=%d", + subreddit, + time_filter, + limit, + ) + + submissions = reddit.subreddit(subreddit).top( + time_filter=time_filter, limit=limit + ) + posts = [_submission_to_post(s) for s in submissions] + + logger.info("Reddit top posts complete results=%d", len(posts)) + return RedditTopPostsResponse( + success=True, + data=RedditTopPostsData( + subreddit=subreddit, + time_filter=time_filter, + posts=posts, + ), + ) + except Exception as e: + logger.exception("Reddit top posts failed") + return RedditTopPostsResponse( + success=False, error=f"Reddit top posts failed: {e}" + ) + + +@tool() +async def reddit_get_hot_posts( + subreddit: str, + limit: int = 10, +) -> RedditHotPostsResponse: + """Get the current hot posts from a subreddit. + + Hot posts are ranked by Reddit's hotness algorithm, which considers + recency and engagement (upvotes, comments). + + Args: + subreddit: Name of the subreddit (e.g. "python"). + limit: Maximum number of posts to return (default 10, max 100). + + Returns: + Hot posts or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditHotPostsResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + limit = max(1, min(limit, 100)) + logger.info("Reddit hot posts subreddit=%s limit=%d", subreddit, limit) + + submissions = reddit.subreddit(subreddit).hot(limit=limit) + posts = [_submission_to_post(s) for s in submissions] + + logger.info("Reddit hot posts complete results=%d", len(posts)) + return RedditHotPostsResponse( + success=True, + data=RedditHotPostsData( + subreddit=subreddit, + posts=posts, + ), + ) + except Exception as e: + logger.exception("Reddit hot posts failed") + return RedditHotPostsResponse( + success=False, error=f"Reddit hot posts failed: {e}" + ) + + +@tool() +async def reddit_get_comments( + post_id: str, + sort: str = "best", + limit: int = 20, +) -> RedditCommentsResponse: + """Get top-level comments for a Reddit post. + + Args: + post_id: The Reddit post ID (e.g. "1abc23d"). + sort: Sort order for comments. One of "best", "top", "new", "controversial", "old", "q&a". Default is "best". + limit: Maximum number of top-level comments to return (default 20, max 100). + + Returns: + List of top-level comments or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditCommentsResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + limit = max(1, min(limit, 100)) + logger.info( + "Reddit get comments post_id=%s sort=%s limit=%d", post_id, sort, limit + ) + + submission = reddit.submission(id=post_id) + submission.comment_sort = sort + submission.comment_limit = limit + submission.comments.replace_more(limit=0) + + comments: list[RedditComment] = [] + for comment in submission.comments[:limit]: + comments.append( + RedditComment( + id=comment.id, + body=comment.body or "", + author=str(comment.author) if comment.author else "[deleted]", + score=comment.score, + created_utc=comment.created_utc, + permalink=f"https://reddit.com{comment.permalink}", + is_submitter=comment.is_submitter, + ) + ) + + logger.info("Reddit get comments complete results=%d", len(comments)) + return RedditCommentsResponse( + success=True, + data=RedditCommentsData( + post_id=post_id, + comments=comments, + ), + ) + except Exception as e: + logger.exception("Reddit get comments failed") + return RedditCommentsResponse( + success=False, error=f"Reddit get comments failed: {e}" + ) + + +@tool() +async def reddit_get_subreddit_info( + subreddit: str, +) -> RedditSubredditInfoResponse: + """Get information about a subreddit including description, subscriber count, and rules. + + Args: + subreddit: Name of the subreddit (e.g. "python"). + + Returns: + Subreddit metadata or an error message. + """ + try: + client_id = await resolve_credential("REDDIT_CLIENT_ID") + client_secret = await resolve_credential("REDDIT_CLIENT_SECRET") + user_agent = await resolve_credential("REDDIT_USER_AGENT") + reddit = _get_reddit_client(client_id, client_secret, user_agent) + if reddit is None: + return RedditSubredditInfoResponse( + success=False, + error="Reddit API not configured. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET.", + ) + + logger.info("Reddit get subreddit info name=%s", subreddit) + + sub = reddit.subreddit(subreddit) + # Force load attributes + _ = sub.display_name + + info = RedditSubredditInfo( + display_name=sub.display_name, + title=sub.title or "", + public_description=sub.public_description or "", + subscribers=sub.subscribers or 0, + active_user_count=getattr(sub, "accounts_active", None), + created_utc=sub.created_utc or 0, + over18=sub.over18 or False, + url=f"https://reddit.com{sub.url}", + ) + + return RedditSubredditInfoResponse( + success=True, + data=RedditSubredditInfoData(subreddit=info), + ) + except Exception as e: + logger.exception("Reddit get subreddit info failed") + return RedditSubredditInfoResponse( + success=False, error=f"Reddit get subreddit info failed: {e}" + ) diff --git a/src/tools/social/schemas.py b/src/tools/social/schemas.py new file mode 100644 index 0000000..84c114f --- /dev/null +++ b/src/tools/social/schemas.py @@ -0,0 +1,545 @@ +"""Pydantic output schemas for social media tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Reddit Schemas +# ============================================================================= + + +class RedditPost(BaseModel): + """A single Reddit post.""" + + id: str = Field(..., description="Post ID") + title: str = Field(..., description="Post title") + score: int = Field(..., description="Post score (upvotes minus downvotes)") + url: str = Field(..., description="Post URL") + selftext: str = Field("", description="Self-text content of the post") + author: str = Field(..., description="Author username") + permalink: str = Field(..., description="Permalink to the post on Reddit") + created_utc: float = Field(..., description="UTC timestamp of post creation") + subreddit: str = Field(..., description="Subreddit name") + num_comments: int = Field(0, description="Number of comments") + + +class RedditSearchData(BaseModel): + """Output data for reddit_search_posts tool.""" + + query: str = Field(..., description="The search query that was executed") + subreddit: str | None = Field(None, description="Subreddit searched in, if any") + results: list[RedditPost] = Field( + default_factory=list, description="List of matching posts" + ) + + +class RedditPostData(BaseModel): + """Output data for reddit_get_post tool.""" + + post: RedditPost = Field(..., description="The requested post") + + +class RedditTopPostsData(BaseModel): + """Output data for reddit_get_top_posts tool.""" + + subreddit: str = Field(..., description="Subreddit name") + time_filter: str = Field(..., description="Time filter used") + posts: list[RedditPost] = Field( + default_factory=list, description="List of top posts" + ) + + +class RedditHotPostsData(BaseModel): + """Output data for reddit_get_hot_posts tool.""" + + subreddit: str = Field(..., description="Subreddit name") + posts: list[RedditPost] = Field( + default_factory=list, description="List of hot posts" + ) + + +class RedditNewPostsData(BaseModel): + """Output data for reddit_get_new_posts tool.""" + + subreddit: str = Field(..., description="Subreddit name") + posts: list[RedditPost] = Field( + default_factory=list, description="List of newest posts" + ) + + +class RedditComment(BaseModel): + """A single Reddit comment.""" + + id: str = Field(..., description="Comment ID") + body: str = Field("", description="Comment text") + author: str = Field("[deleted]", description="Author username") + score: int = Field(0, description="Comment score") + created_utc: float = Field(0, description="UTC timestamp of creation") + permalink: str = Field("", description="Permalink to the comment") + is_submitter: bool = Field( + False, description="Whether the author is the post author" + ) + + +class RedditCommentsData(BaseModel): + """Output data for reddit_get_comments tool.""" + + post_id: str = Field(..., description="Parent post ID") + comments: list[RedditComment] = Field( + default_factory=list, description="List of top-level comments" + ) + + +class RedditSubredditInfo(BaseModel): + """Information about a subreddit.""" + + display_name: str = Field(..., description="Subreddit display name") + title: str = Field("", description="Subreddit title") + public_description: str = Field("", description="Public description") + subscribers: int = Field(0, description="Number of subscribers") + active_user_count: int | None = Field(None, description="Active users online") + created_utc: float = Field(0, description="UTC timestamp of creation") + over18: bool = Field(False, description="Whether the subreddit is NSFW") + url: str = Field("", description="Subreddit URL path") + + +class RedditSubredditInfoData(BaseModel): + """Output data for reddit_get_subreddit_info tool.""" + + subreddit: RedditSubredditInfo = Field(..., description="Subreddit information") + + +# ============================================================================= +# X / Twitter Schemas +# ============================================================================= + + +class XTweetPublicMetrics(BaseModel): + """Public engagement metrics for a tweet.""" + + retweet_count: int = Field(0, description="Number of retweets") + reply_count: int = Field(0, description="Number of replies") + like_count: int = Field(0, description="Number of likes") + quote_count: int = Field(0, description="Number of quote tweets") + bookmark_count: int = Field(0, description="Number of bookmarks") + impression_count: int = Field(0, description="Number of impressions") + + +class XTweet(BaseModel): + """A single X/Twitter tweet.""" + + id: str = Field(..., description="Tweet ID") + text: str = Field(..., description="Tweet text content") + author_id: str | None = Field(None, description="Author user ID") + author_username: str | None = Field(None, description="Author username") + created_at: str | None = Field(None, description="Creation timestamp") + url: str | None = Field(None, description="URL to the tweet") + public_metrics: XTweetPublicMetrics | None = Field( + None, description="Public engagement metrics" + ) + + +class XPostTweetData(BaseModel): + """Output data for x_post_tweet tool.""" + + id: str = Field(..., description="Created tweet ID") + text: str = Field(..., description="Tweet text") + url: str = Field(..., description="URL to the created tweet") + + +class XSearchTweetsData(BaseModel): + """Output data for x_search_tweets tool.""" + + query: str = Field(..., description="The search query that was executed") + count: int = Field(0, description="Number of results returned") + tweets: list[XTweet] = Field( + default_factory=list, description="List of matching tweets" + ) + + +class XUserTweetsData(BaseModel): + """Output data for x_get_user_tweets tool.""" + + user_id: str = Field(..., description="User ID whose tweets were fetched") + username: str | None = Field(None, description="Username / handle") + count: int = Field(0, description="Number of tweets returned") + tweets: list[XTweet] = Field( + default_factory=list, description="List of user tweets" + ) + + +class XUserData(BaseModel): + """Output data for x_get_user tool.""" + + id: str = Field(..., description="User ID") + name: str = Field(..., description="Display name") + username: str = Field(..., description="Username / handle") + description: str | None = Field(None, description="User bio") + profile_image_url: str | None = Field(None, description="Profile image URL") + location: str | None = Field(None, description="User location") + url: str | None = Field(None, description="User website URL") + created_at: str | None = Field(None, description="Account creation date (ISO)") + verified: bool = Field(False, description="Whether the user is verified") + followers_count: int = Field(0, description="Number of followers") + following_count: int = Field(0, description="Number of accounts followed") + tweet_count: int = Field(0, description="Total number of tweets") + listed_count: int = Field(0, description="Number of lists the user is a member of") + + +# ============================================================================= +# Hacker News Schemas +# ============================================================================= + + +class HackerNewsStory(BaseModel): + """A single Hacker News story.""" + + id: int = Field(..., description="Story ID") + title: str = Field(..., description="Story title") + url: str | None = Field(None, description="Story URL (None for Ask HN etc.)") + score: int = Field(0, description="Story score") + by: str = Field(..., description="Author username") + time: int = Field(..., description="Unix timestamp of creation") + descendants: int = Field(0, description="Number of comments") + type: str = Field("story", description="Item type") + + +class HackerNewsTopStoriesData(BaseModel): + """Output data for hackernews_get_top_stories tool.""" + + stories: list[HackerNewsStory] = Field( + default_factory=list, description="List of top stories" + ) + + +class HackerNewsStoryData(BaseModel): + """Output data for hackernews_get_story tool.""" + + story: HackerNewsStory = Field(..., description="The requested story") + + +class HackerNewsSearchResult(BaseModel): + """A single Hacker News search result from Algolia.""" + + objectID: str = Field(..., description="Story ID") + title: str = Field(..., description="Story title") + url: str | None = Field(None, description="Story URL") + points: int | None = Field(None, description="Story score / points") + author: str | None = Field(None, description="Author username") + created_at: str | None = Field(None, description="Creation timestamp (ISO)") + num_comments: int | None = Field(None, description="Number of comments") + + +class HackerNewsSearchData(BaseModel): + """Output data for hackernews_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[HackerNewsSearchResult] = Field( + default_factory=list, description="List of search results" + ) + + +class HackerNewsUser(BaseModel): + """A Hacker News user profile.""" + + id: str = Field(..., description="Username") + created: int = Field(..., description="Unix timestamp of account creation") + karma: int = Field(0, description="User karma score") + about: str | None = Field(None, description="User bio (HTML)") + submitted: list[int] = Field( + default_factory=list, + description="List of submitted item IDs (most recent first)", + ) + + +class HackerNewsUserData(BaseModel): + """Output data for hackernews_get_user tool.""" + + user: HackerNewsUser = Field(..., description="The requested user profile") + + +class HackerNewsComment(BaseModel): + """A single Hacker News comment.""" + + id: int = Field(..., description="Comment ID") + by: str | None = Field(None, description="Author username") + text: str | None = Field(None, description="Comment text (HTML)") + time: int = Field(0, description="Unix timestamp of creation") + parent: int = Field(0, description="Parent item ID") + kids: list[int] = Field(default_factory=list, description="Child comment IDs") + + +class HackerNewsCommentsData(BaseModel): + """Output data for hackernews_get_comments tool.""" + + story_id: int = Field(..., description="Parent story ID") + comments: list[HackerNewsComment] = Field( + default_factory=list, description="List of comments" + ) + + +# ============================================================================= +# YouTube Schemas +# ============================================================================= + + +class YouTubeVideo(BaseModel): + """A single YouTube video search result.""" + + video_id: str = Field(..., description="YouTube video ID") + title: str = Field(..., description="Video title") + description: str | None = Field(None, description="Video description snippet") + channel_id: str | None = Field(None, description="Channel ID") + channel_title: str | None = Field(None, description="Channel name") + published_at: str | None = Field(None, description="Publish date (ISO)") + thumbnail_url: str | None = Field(None, description="Thumbnail URL") + + +class YouTubeSearchData(BaseModel): + """Output data for youtube_search tool.""" + + query: str = Field(..., description="The search query that was executed") + results: list[YouTubeVideo] = Field( + default_factory=list, description="List of matching videos" + ) + + +class YouTubeVideoInfoData(BaseModel): + """Output data for youtube_get_video_info tool.""" + + video_id: str = Field(..., description="YouTube video ID") + title: str = Field(..., description="Video title") + description: str | None = Field(None, description="Video description") + channel_title: str | None = Field(None, description="Channel name") + published_at: str | None = Field(None, description="Publish date (ISO)") + view_count: int | None = Field(None, description="Number of views") + like_count: int | None = Field(None, description="Number of likes") + comment_count: int | None = Field(None, description="Number of comments") + duration: str | None = Field(None, description="Video duration (ISO 8601)") + thumbnail_url: str | None = Field(None, description="Thumbnail URL") + + +class YouTubeCaptionsData(BaseModel): + """Output data for youtube_get_captions tool.""" + + video_id: str = Field(..., description="YouTube video ID") + captions: str = Field(..., description="Concatenated caption text") + + +class YouTubeChannelData(BaseModel): + """Output data for youtube_get_channel tool.""" + + channel_id: str = Field(..., description="YouTube channel ID") + title: str = Field(..., description="Channel name") + description: str | None = Field(None, description="Channel description") + custom_url: str | None = Field(None, description="Custom channel URL handle") + published_at: str | None = Field(None, description="Channel creation date (ISO)") + thumbnail_url: str | None = Field(None, description="Channel thumbnail URL") + subscriber_count: int | None = Field(None, description="Number of subscribers") + video_count: int | None = Field(None, description="Number of uploaded videos") + view_count: int | None = Field(None, description="Total channel views") + uploads_playlist_id: str | None = Field( + None, description="Playlist ID for the channel's uploaded videos" + ) + + +class YouTubePlaylistItem(BaseModel): + """A single item in a YouTube playlist.""" + + video_id: str = Field(..., description="YouTube video ID") + title: str = Field(..., description="Video title") + description: str | None = Field(None, description="Video description snippet") + channel_id: str | None = Field(None, description="Video owner channel ID") + channel_title: str | None = Field(None, description="Video owner channel name") + published_at: str | None = Field(None, description="Date added to playlist (ISO)") + position: int = Field(0, description="Position in the playlist") + thumbnail_url: str | None = Field(None, description="Thumbnail URL") + + +class YouTubePlaylistItemsData(BaseModel): + """Output data for youtube_get_playlist_items tool.""" + + playlist_id: str = Field(..., description="Playlist ID") + items: list[YouTubePlaylistItem] = Field( + default_factory=list, description="List of playlist items" + ) + total_results: int | None = Field(None, description="Total items in the playlist") + + +class YouTubeComment(BaseModel): + """A single YouTube comment.""" + + comment_id: str = Field(..., description="Comment ID") + text: str = Field(..., description="Comment text") + author: str = Field(..., description="Comment author display name") + author_channel_id: str | None = Field(None, description="Author channel ID") + like_count: int = Field(0, description="Number of likes on the comment") + published_at: str | None = Field(None, description="Publish date (ISO)") + updated_at: str | None = Field(None, description="Last update date (ISO)") + reply_count: int = Field(0, description="Number of replies to this comment") + + +class YouTubeCommentsData(BaseModel): + """Output data for youtube_get_comments tool.""" + + video_id: str = Field(..., description="YouTube video ID") + comments: list[YouTubeComment] = Field( + default_factory=list, description="List of top-level comments" + ) + total_results: int | None = Field(None, description="Total number of comments") + + +class XGetTweetData(BaseModel): + """Output data for x_get_tweet tool.""" + + tweet: XTweet = Field(..., description="The requested tweet") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class RedditSearchResponse(ToolResponse[RedditSearchData]): + """Response schema for reddit_search_posts tool.""" + + pass + + +class RedditPostResponse(ToolResponse[RedditPostData]): + """Response schema for reddit_get_post tool.""" + + pass + + +class RedditTopPostsResponse(ToolResponse[RedditTopPostsData]): + """Response schema for reddit_get_top_posts tool.""" + + pass + + +class XPostTweetResponse(ToolResponse[XPostTweetData]): + """Response schema for x_post_tweet tool.""" + + pass + + +class XSearchTweetsResponse(ToolResponse[XSearchTweetsData]): + """Response schema for x_search_tweets tool.""" + + pass + + +class XUserResponse(ToolResponse[XUserData]): + """Response schema for x_get_user tool.""" + + pass + + +class HackerNewsTopStoriesResponse(ToolResponse[HackerNewsTopStoriesData]): + """Response schema for hackernews_get_top_stories tool.""" + + pass + + +class HackerNewsStoryResponse(ToolResponse[HackerNewsStoryData]): + """Response schema for hackernews_get_story tool.""" + + pass + + +class HackerNewsSearchResponse(ToolResponse[HackerNewsSearchData]): + """Response schema for hackernews_search tool.""" + + pass + + +class YouTubeSearchResponse(ToolResponse[YouTubeSearchData]): + """Response schema for youtube_search tool.""" + + pass + + +class YouTubeVideoInfoResponse(ToolResponse[YouTubeVideoInfoData]): + """Response schema for youtube_get_video_info tool.""" + + pass + + +class YouTubeCaptionsResponse(ToolResponse[YouTubeCaptionsData]): + """Response schema for youtube_get_captions tool.""" + + pass + + +class YouTubeChannelResponse(ToolResponse[YouTubeChannelData]): + """Response schema for youtube_get_channel tool.""" + + pass + + +class YouTubePlaylistItemsResponse(ToolResponse[YouTubePlaylistItemsData]): + """Response schema for youtube_get_playlist_items tool.""" + + pass + + +class HackerNewsUserResponse(ToolResponse[HackerNewsUserData]): + """Response schema for hackernews_get_user tool.""" + + pass + + +class HackerNewsCommentsResponse(ToolResponse[HackerNewsCommentsData]): + """Response schema for hackernews_get_comments tool.""" + + pass + + +class HackerNewsSearchByDateResponse(ToolResponse[HackerNewsSearchData]): + """Response schema for hackernews_search_by_date tool.""" + + pass + + +class RedditHotPostsResponse(ToolResponse[RedditHotPostsData]): + """Response schema for reddit_get_hot_posts tool.""" + + pass + + +class RedditCommentsResponse(ToolResponse[RedditCommentsData]): + """Response schema for reddit_get_comments tool.""" + + pass + + +class RedditSubredditInfoResponse(ToolResponse[RedditSubredditInfoData]): + """Response schema for reddit_get_subreddit_info tool.""" + + pass + + +class XUserTweetsResponse(ToolResponse[XUserTweetsData]): + """Response schema for x_get_user_tweets tool.""" + + pass + + +class RedditNewPostsResponse(ToolResponse[RedditNewPostsData]): + """Response schema for reddit_get_new_posts tool.""" + + pass + + +class YouTubeCommentsResponse(ToolResponse[YouTubeCommentsData]): + """Response schema for youtube_get_comments tool.""" + + pass + + +class XGetTweetResponse(ToolResponse[XGetTweetData]): + """Response schema for x_get_tweet tool.""" + + pass diff --git a/src/tools/social/x.py b/src/tools/social/x.py new file mode 100644 index 0000000..7f809c3 --- /dev/null +++ b/src/tools/social/x.py @@ -0,0 +1,400 @@ +"""X (Twitter) tools for posting tweets, searching, and fetching user info via API v2.""" + +from __future__ import annotations + +import logging +import os + +import httpx + +from src.humcp.decorator import tool +from src.tools.social.schemas import ( + XPostTweetData, + XPostTweetResponse, + XSearchTweetsData, + XSearchTweetsResponse, + XTweet, + XTweetPublicMetrics, + XUserData, + XUserResponse, + XUserTweetsData, + XUserTweetsResponse, +) + +logger = logging.getLogger("humcp.tools.x") + +_BASE_URL = "https://api.twitter.com/2" + + +def _get_bearer_headers() -> dict[str, str] | None: + """Return Authorization headers using the bearer token, or None if not configured.""" + token = os.getenv("X_BEARER_TOKEN") + if not token: + return None + return {"Authorization": f"Bearer {token}"} + + +def _get_oauth_headers(method: str, url: str) -> dict[str, str] | None: + """Return OAuth 1.0a headers for write operations. + + Requires X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_TOKEN_SECRET. + Uses the authlib library for OAuth1 signing. + """ + api_key = os.getenv("X_API_KEY") + api_secret = os.getenv("X_API_SECRET") + access_token = os.getenv("X_ACCESS_TOKEN") + access_token_secret = os.getenv("X_ACCESS_TOKEN_SECRET") + + if not all([api_key, api_secret, access_token, access_token_secret]): + return None + + try: + from authlib.integrations.httpx_client import OAuth1Auth # type: ignore + + auth = OAuth1Auth( + client_id=api_key, + client_secret=api_secret, + token=access_token, + token_secret=access_token_secret, + ) + # Build a dummy request to sign + req = httpx.Request(method, url) + signed = auth(req) + return dict(signed.headers) + except ImportError: + logger.warning( + "authlib is required for OAuth1 signing. Install with: pip install authlib" + ) + return None + + +@tool() +async def x_post_tweet(text: str) -> XPostTweetResponse: + """Post a new tweet on X (Twitter). + + Args: + text: The text content of the tweet (max 280 characters). + + Returns: + The created tweet details or an error message. + """ + try: + url = f"{_BASE_URL}/tweets" + headers = _get_oauth_headers("POST", url) + if headers is None: + return XPostTweetResponse( + success=False, + error="X API not configured for posting. Set X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_TOKEN_SECRET.", + ) + + if len(text) > 280: + return XPostTweetResponse( + success=False, + error=f"Tweet text exceeds 280 characters (got {len(text)}).", + ) + + logger.info("X post tweet length=%d", len(text)) + headers["Content-Type"] = "application/json" + + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers=headers, + json={"text": text}, + ) + response.raise_for_status() + data = response.json() + + tweet_id = data["data"]["id"] + tweet_text = data["data"]["text"] + tweet_url = f"https://x.com/i/status/{tweet_id}" + + logger.info("X tweet posted id=%s", tweet_id) + return XPostTweetResponse( + success=True, + data=XPostTweetData( + id=tweet_id, + text=tweet_text, + url=tweet_url, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X post tweet HTTP error") + return XPostTweetResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X post tweet failed") + return XPostTweetResponse(success=False, error=f"X post tweet failed: {e}") + + +def _parse_tweet(tweet_data: dict, users_map: dict[str, str]) -> XTweet: + """Parse a single tweet response dict into an XTweet model.""" + author_id = tweet_data.get("author_id") + username = users_map.get(author_id, "unknown") if author_id else "unknown" + + metrics = tweet_data.get("public_metrics") + public_metrics = None + if metrics: + public_metrics = XTweetPublicMetrics( + retweet_count=metrics.get("retweet_count", 0), + reply_count=metrics.get("reply_count", 0), + like_count=metrics.get("like_count", 0), + quote_count=metrics.get("quote_count", 0), + bookmark_count=metrics.get("bookmark_count", 0), + impression_count=metrics.get("impression_count", 0), + ) + + return XTweet( + id=tweet_data["id"], + text=tweet_data["text"], + author_id=author_id, + author_username=username, + created_at=tweet_data.get("created_at"), + url=f"https://x.com/{username}/status/{tweet_data['id']}", + public_metrics=public_metrics, + ) + + +@tool() +async def x_search_tweets( + query: str, + max_results: int = 10, +) -> XSearchTweetsResponse: + """Search recent tweets on X (Twitter) matching a query. + + Uses the Twitter API v2 recent search endpoint. Returns tweets from the + last 7 days matching the query, including public engagement metrics. + + Args: + query: The search query string. Supports Twitter search operators (e.g. "from:user", "#hashtag", "lang:en"). + max_results: Maximum number of results to return (10-100, default 10). + + Returns: + Matching tweets with public metrics or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XSearchTweetsResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + max_results = max(10, min(max_results, 100)) + logger.info("X search query=%r max_results=%d", query, max_results) + + params = { + "query": query, + "max_results": max_results, + "tweet.fields": "author_id,created_at,text,public_metrics", + "expansions": "author_id", + "user.fields": "username", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_BASE_URL}/tweets/search/recent", + headers=headers, + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + data = response.json() + + # Build user lookup + users_map: dict[str, str] = {} + for user in data.get("includes", {}).get("users", []): + users_map[user["id"]] = user.get("username", "unknown") + + tweets: list[XTweet] = [ + _parse_tweet(td, users_map) for td in data.get("data", []) + ] + + logger.info("X search complete results=%d", len(tweets)) + return XSearchTweetsResponse( + success=True, + data=XSearchTweetsData( + query=query, + count=len(tweets), + tweets=tweets, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X search tweets HTTP error") + return XSearchTweetsResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X search tweets failed") + return XSearchTweetsResponse( + success=False, error=f"X search tweets failed: {e}" + ) + + +@tool() +async def x_get_user(username: str) -> XUserResponse: + """Get public profile information for an X (Twitter) user. + + Returns the user's profile details including bio, location, profile image, + account creation date, verification status, and public metrics. + + Args: + username: The X username / handle (without the @ symbol). + + Returns: + User profile details or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XUserResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + logger.info("X get user username=%s", username) + + params = { + "user.fields": "created_at,description,location,profile_image_url,public_metrics,url,verified", + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_BASE_URL}/users/by/username/{username}", + headers=headers, + params=params, + ) + response.raise_for_status() + data = response.json() + + user_data = data.get("data") + if not user_data: + return XUserResponse( + success=False, + error=f"User '{username}' not found.", + ) + + metrics = user_data.get("public_metrics", {}) + return XUserResponse( + success=True, + data=XUserData( + id=user_data["id"], + name=user_data["name"], + username=user_data["username"], + description=user_data.get("description"), + profile_image_url=user_data.get("profile_image_url"), + location=user_data.get("location"), + url=user_data.get("url"), + created_at=user_data.get("created_at"), + verified=user_data.get("verified", False), + followers_count=metrics.get("followers_count", 0), + following_count=metrics.get("following_count", 0), + tweet_count=metrics.get("tweet_count", 0), + listed_count=metrics.get("listed_count", 0), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X get user HTTP error") + return XUserResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X get user failed") + return XUserResponse(success=False, error=f"X get user failed: {e}") + + +@tool() +async def x_get_user_tweets( + username: str, + max_results: int = 10, +) -> XUserTweetsResponse: + """Get recent tweets posted by a specific X (Twitter) user. + + First resolves the username to a user ID, then fetches their recent tweets + including public engagement metrics (likes, retweets, replies, etc.). + + Args: + username: The X username / handle (without the @ symbol). + max_results: Maximum number of tweets to return (5-100, default 10). + + Returns: + List of the user's recent tweets or an error message. + """ + try: + headers = _get_bearer_headers() + if headers is None: + return XUserTweetsResponse( + success=False, + error="X API not configured. Set X_BEARER_TOKEN environment variable.", + ) + + max_results = max(5, min(max_results, 100)) + logger.info( + "X get user tweets username=%s max_results=%d", username, max_results + ) + + async with httpx.AsyncClient() as client: + # First resolve username to user ID + user_resp = await client.get( + f"{_BASE_URL}/users/by/username/{username}", + headers=headers, + params={"user.fields": "id"}, + ) + user_resp.raise_for_status() + user_json = user_resp.json() + + user_data = user_json.get("data") + if not user_data: + return XUserTweetsResponse( + success=False, + error=f"User '{username}' not found.", + ) + + user_id = user_data["id"] + + async with httpx.AsyncClient() as client: + tweets_resp = await client.get( + f"{_BASE_URL}/users/{user_id}/tweets", + headers=headers, + params={ + "max_results": max_results, + "tweet.fields": "author_id,created_at,text,public_metrics", + "expansions": "author_id", + "user.fields": "username", + }, + ) + tweets_resp.raise_for_status() + data = tweets_resp.json() + + users_map: dict[str, str] = {} + for user in data.get("includes", {}).get("users", []): + users_map[user["id"]] = user.get("username", "unknown") + + tweets: list[XTweet] = [ + _parse_tweet(td, users_map) for td in data.get("data", []) + ] + + logger.info("X get user tweets complete results=%d", len(tweets)) + return XUserTweetsResponse( + success=True, + data=XUserTweetsData( + user_id=user_id, + username=username, + count=len(tweets), + tweets=tweets, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("X get user tweets HTTP error") + return XUserTweetsResponse( + success=False, + error=f"X API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("X get user tweets failed") + return XUserTweetsResponse( + success=False, error=f"X get user tweets failed: {e}" + ) diff --git a/src/tools/social/youtube.py b/src/tools/social/youtube.py new file mode 100644 index 0000000..71cba03 --- /dev/null +++ b/src/tools/social/youtube.py @@ -0,0 +1,438 @@ +"""YouTube tools for searching videos, fetching video info, and retrieving captions.""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.social.schemas import ( + YouTubeCaptionsData, + YouTubeCaptionsResponse, + YouTubeChannelData, + YouTubeChannelResponse, + YouTubePlaylistItem, + YouTubePlaylistItemsData, + YouTubePlaylistItemsResponse, + YouTubeSearchData, + YouTubeSearchResponse, + YouTubeVideo, + YouTubeVideoInfoData, + YouTubeVideoInfoResponse, +) + +logger = logging.getLogger("humcp.tools.youtube") + +_YT_API_BASE = "https://www.googleapis.com/youtube/v3" + + +@tool() +async def youtube_search( + query: str, + max_results: int = 5, +) -> YouTubeSearchResponse: + """Search YouTube videos by query. + + Args: + query: The search query string. + max_results: Maximum number of video results to return (default 5, max 50). + + Returns: + Matching videos or an error message. + """ + try: + api_key = await resolve_credential("YOUTUBE_API_KEY") + if not api_key: + return YouTubeSearchResponse( + success=False, + error="YouTube API not configured. Set YOUTUBE_API_KEY environment variable.", + ) + + max_results = max(1, min(max_results, 50)) + logger.info("YouTube search query=%r max_results=%d", query, max_results) + + params = { + "part": "snippet", + "q": query, + "type": "video", + "maxResults": max_results, + "key": api_key, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_YT_API_BASE}/search", + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + data = response.json() + + videos: list[YouTubeVideo] = [] + for item in data.get("items", []): + snippet = item.get("snippet", {}) + video_id = item.get("id", {}).get("videoId", "") + thumbnails = snippet.get("thumbnails", {}) + thumbnail_url = thumbnails.get("high", thumbnails.get("default", {})).get( + "url" + ) + + videos.append( + YouTubeVideo( + video_id=video_id, + title=snippet.get("title", ""), + description=snippet.get("description"), + channel_id=snippet.get("channelId"), + channel_title=snippet.get("channelTitle"), + published_at=snippet.get("publishedAt"), + thumbnail_url=thumbnail_url, + ) + ) + + logger.info("YouTube search complete results=%d", len(videos)) + return YouTubeSearchResponse( + success=True, + data=YouTubeSearchData( + query=query, + results=videos, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("YouTube search HTTP error") + return YouTubeSearchResponse( + success=False, + error=f"YouTube API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("YouTube search failed") + return YouTubeSearchResponse(success=False, error=f"YouTube search failed: {e}") + + +@tool() +async def youtube_get_video_info(video_id: str) -> YouTubeVideoInfoResponse: + """Get detailed information about a YouTube video. + + Args: + video_id: The YouTube video ID (e.g. "dQw4w9WgXcQ"). + + Returns: + Video details including statistics, or an error message. + """ + try: + api_key = await resolve_credential("YOUTUBE_API_KEY") + if not api_key: + return YouTubeVideoInfoResponse( + success=False, + error="YouTube API not configured. Set YOUTUBE_API_KEY environment variable.", + ) + + logger.info("YouTube get video info id=%s", video_id) + + params = { + "part": "snippet,contentDetails,statistics", + "id": video_id, + "key": api_key, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_YT_API_BASE}/videos", + params=params, + ) + response.raise_for_status() + data = response.json() + + items = data.get("items", []) + if not items: + return YouTubeVideoInfoResponse( + success=False, + error=f"Video with ID '{video_id}' not found.", + ) + + item = items[0] + snippet = item.get("snippet", {}) + stats = item.get("statistics", {}) + content_details = item.get("contentDetails", {}) + thumbnails = snippet.get("thumbnails", {}) + thumbnail_url = thumbnails.get("high", thumbnails.get("default", {})).get("url") + + def _safe_int(value: str | None) -> int | None: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + return YouTubeVideoInfoResponse( + success=True, + data=YouTubeVideoInfoData( + video_id=video_id, + title=snippet.get("title", ""), + description=snippet.get("description"), + channel_title=snippet.get("channelTitle"), + published_at=snippet.get("publishedAt"), + view_count=_safe_int(stats.get("viewCount")), + like_count=_safe_int(stats.get("likeCount")), + comment_count=_safe_int(stats.get("commentCount")), + duration=content_details.get("duration"), + thumbnail_url=thumbnail_url, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("YouTube get video info HTTP error") + return YouTubeVideoInfoResponse( + success=False, + error=f"YouTube API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("YouTube get video info failed") + return YouTubeVideoInfoResponse( + success=False, error=f"YouTube get video info failed: {e}" + ) + + +@tool() +async def youtube_get_captions(video_id: str) -> YouTubeCaptionsResponse: + """Get the captions (transcript) of a YouTube video. + + Uses the youtube-transcript-api library to fetch available captions. + + Args: + video_id: The YouTube video ID (e.g. "dQw4w9WgXcQ"). + + Returns: + Concatenated caption text or an error message. + """ + try: + try: + from youtube_transcript_api import YouTubeTranscriptApi # type: ignore + except ImportError: + return YouTubeCaptionsResponse( + success=False, + error="youtube-transcript-api is required for captions. Install with: pip install youtube-transcript-api", + ) + + logger.info("YouTube get captions id=%s", video_id) + + captions = YouTubeTranscriptApi().fetch(video_id) + if not captions: + return YouTubeCaptionsResponse( + success=False, + error=f"No captions found for video '{video_id}'.", + ) + + caption_text = " ".join(line.text for line in captions) + + logger.info("YouTube captions complete length=%d", len(caption_text)) + return YouTubeCaptionsResponse( + success=True, + data=YouTubeCaptionsData( + video_id=video_id, + captions=caption_text, + ), + ) + except Exception as e: + logger.exception("YouTube get captions failed") + return YouTubeCaptionsResponse( + success=False, error=f"YouTube get captions failed: {e}" + ) + + +@tool() +async def youtube_get_channel( + channel_id: str | None = None, + username: str | None = None, +) -> YouTubeChannelResponse: + """Get information about a YouTube channel. + + Provide either a channel_id or a username. Returns channel metadata + including subscriber count, video count, and the uploads playlist ID. + + Args: + channel_id: The YouTube channel ID (e.g. "UC_x5XG1OV2P6uZZ5FSM9Ttw"). Provide this or username. + username: The YouTube channel username/handle (e.g. "GoogleDevelopers"). Provide this or channel_id. + + Returns: + Channel details including statistics or an error message. + """ + try: + api_key = await resolve_credential("YOUTUBE_API_KEY") + if not api_key: + return YouTubeChannelResponse( + success=False, + error="YouTube API not configured. Set YOUTUBE_API_KEY environment variable.", + ) + + if not channel_id and not username: + return YouTubeChannelResponse( + success=False, + error="Provide either channel_id or username.", + ) + + logger.info("YouTube get channel id=%s username=%s", channel_id, username) + + params: dict = { + "part": "snippet,statistics,contentDetails", + "key": api_key, + } + if channel_id: + params["id"] = channel_id + else: + params["forHandle"] = username + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_YT_API_BASE}/channels", + params=params, + ) + response.raise_for_status() + data = response.json() + + items = data.get("items", []) + if not items: + return YouTubeChannelResponse( + success=False, + error=f"Channel not found for {'id=' + channel_id if channel_id else 'username=' + str(username)}.", + ) + + item = items[0] + snippet = item.get("snippet", {}) + stats = item.get("statistics", {}) + content_details = item.get("contentDetails", {}) + thumbnails = snippet.get("thumbnails", {}) + thumbnail_url = thumbnails.get("high", thumbnails.get("default", {})).get("url") + + def _safe_int(value: str | None) -> int | None: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + uploads_playlist = content_details.get("relatedPlaylists", {}).get("uploads") + + return YouTubeChannelResponse( + success=True, + data=YouTubeChannelData( + channel_id=item["id"], + title=snippet.get("title", ""), + description=snippet.get("description"), + custom_url=snippet.get("customUrl"), + published_at=snippet.get("publishedAt"), + thumbnail_url=thumbnail_url, + subscriber_count=_safe_int(stats.get("subscriberCount")), + video_count=_safe_int(stats.get("videoCount")), + view_count=_safe_int(stats.get("viewCount")), + uploads_playlist_id=uploads_playlist, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("YouTube get channel HTTP error") + return YouTubeChannelResponse( + success=False, + error=f"YouTube API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("YouTube get channel failed") + return YouTubeChannelResponse( + success=False, error=f"YouTube get channel failed: {e}" + ) + + +@tool() +async def youtube_get_playlist_items( + playlist_id: str, + max_results: int = 10, +) -> YouTubePlaylistItemsResponse: + """Get videos from a YouTube playlist. + + Retrieves items from any playlist, including a channel's uploads playlist + (use youtube_get_channel to get the uploads_playlist_id first). + + Args: + playlist_id: The YouTube playlist ID (e.g. "PLIivdWyY5sqJxnwJhe3ETaK6uFCfkheUk"). + max_results: Maximum number of items to return (default 10, max 50). + + Returns: + List of playlist items or an error message. + """ + try: + api_key = await resolve_credential("YOUTUBE_API_KEY") + if not api_key: + return YouTubePlaylistItemsResponse( + success=False, + error="YouTube API not configured. Set YOUTUBE_API_KEY environment variable.", + ) + + max_results = max(1, min(max_results, 50)) + logger.info( + "YouTube get playlist items id=%s max_results=%d", playlist_id, max_results + ) + + params = { + "part": "snippet,contentDetails", + "playlistId": playlist_id, + "maxResults": max_results, + "key": api_key, + } + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_YT_API_BASE}/playlistItems", + params=params, # type: ignore[arg-type] + ) + response.raise_for_status() + data = response.json() + + page_info = data.get("pageInfo", {}) + total_results = page_info.get("totalResults") + + items: list[YouTubePlaylistItem] = [] + for item in data.get("items", []): + snippet = item.get("snippet", {}) + content_details = item.get("contentDetails", {}) + thumbnails = snippet.get("thumbnails", {}) + thumbnail_url = thumbnails.get("high", thumbnails.get("default", {})).get( + "url" + ) + + video_id = content_details.get( + "videoId", snippet.get("resourceId", {}).get("videoId", "") + ) + + items.append( + YouTubePlaylistItem( + video_id=video_id, + title=snippet.get("title", ""), + description=snippet.get("description"), + channel_id=snippet.get("videoOwnerChannelId"), + channel_title=snippet.get("videoOwnerChannelTitle"), + published_at=snippet.get("publishedAt"), + position=snippet.get("position", 0), + thumbnail_url=thumbnail_url, + ) + ) + + logger.info("YouTube get playlist items complete results=%d", len(items)) + return YouTubePlaylistItemsResponse( + success=True, + data=YouTubePlaylistItemsData( + playlist_id=playlist_id, + items=items, + total_results=total_results, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("YouTube get playlist items HTTP error") + return YouTubePlaylistItemsResponse( + success=False, + error=f"YouTube API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("YouTube get playlist items failed") + return YouTubePlaylistItemsResponse( + success=False, error=f"YouTube get playlist items failed: {e}" + ) diff --git a/src/tools/storage/SKILL.md b/src/tools/storage/SKILL.md new file mode 100644 index 0000000..837662e --- /dev/null +++ b/src/tools/storage/SKILL.md @@ -0,0 +1,148 @@ +--- +name: storage +description: Use these tools for S3-compatible object storage operations with MinIO +--- + +# Storage Tools (MinIO) + +Tools for interacting with MinIO S3-compatible object storage. Use these for file uploads, downloads, and bucket management. + +## Configuration + +Set these environment variables: + +```bash +MINIO_ENDPOINT=minio:9000 # MinIO server address +MINIO_ACCESS_KEY=minioadmin # Access key (required) +MINIO_SECRET_KEY=minioadmin # Secret key (required) +MINIO_SECURE=false # Use HTTPS (true/false) +MINIO_ALLOWED_BUCKETS=bucket1,bucket2 # Comma-separated allowlist +``` + +## Security + +All bucket operations validate against `MINIO_ALLOWED_BUCKETS`. If set, only those buckets can be accessed. + +## Available Tools + +### Bucket Operations + +| Tool | Description | +|------|-------------| +| `list_buckets` | List all accessible buckets | +| `create_bucket` | Create a new bucket | +| `delete_bucket` | Delete an empty bucket | +| `bucket_exists` | Check if a bucket exists | + +### Object Listing + +| Tool | Description | +|------|-------------| +| `list_objects` | List objects with optional prefix filter | + +### Upload + +| Tool | Description | +|------|-------------| +| `upload_content` | Upload base64-encoded content | +| `upload_from_path` | Upload from local file path | + +### Download + +| Tool | Description | +|------|-------------| +| `download_content` | Download as base64-encoded content | +| `download_to_path` | Download to local file path | + +### Delete & Copy + +| Tool | Description | +|------|-------------| +| `delete_object` | Delete an object | +| `copy_object` | Copy object between buckets | + +### Utilities + +| Tool | Description | +|------|-------------| +| `get_object_metadata` | Get object size, type, and metadata | +| `get_presigned_url` | Generate temporary download URL | + +## Usage Examples + +### Upload a file + +```python +# From base64 content +result = await upload_content( + bucket="my-bucket", + object_name="documents/report.pdf", + content_base64="JVBERi0xLjQK...", + content_type="application/pdf" +) + +# From local path +result = await upload_from_path( + bucket="my-bucket", + object_name="images/photo.jpg", + file_path="/tmp/photo.jpg", + content_type="image/jpeg" +) +``` + +### Download a file + +```python +# To base64 (for small files) +result = await download_content(bucket="my-bucket", object_name="doc.pdf") +content = base64.b64decode(result["data"]["content_base64"]) + +# To local path (for large files) +result = await download_to_path( + bucket="my-bucket", + object_name="video.mp4", + file_path="/tmp/video.mp4" +) +``` + +### List objects with prefix + +```python +result = await list_objects( + bucket="my-bucket", + prefix="documents/2024/", + recursive=True +) +# Returns all objects under documents/2024/ +``` + +### Generate presigned URL + +```python +result = await get_presigned_url( + bucket="my-bucket", + object_name="private/report.pdf", + expires_hours=24 +) +# Share result["data"]["url"] for temporary access +``` + +## Response Format + +All tools return a standardized response: + +```python +# Success +{"success": True, "data": {...}} + +# Error +{"success": False, "error": "Error message"} +``` + +## When to Use + +- **upload_content**: Small files (<10MB), content already in memory +- **upload_from_path**: Large files, files on disk +- **download_content**: Small files, need content in memory +- **download_to_path**: Large files, need to save to disk +- **get_presigned_url**: Share temporary access without exposing credentials diff --git a/src/tools/storage/__init__.py b/src/tools/storage/__init__.py new file mode 100644 index 0000000..6b42ce0 --- /dev/null +++ b/src/tools/storage/__init__.py @@ -0,0 +1 @@ +# S3-compatible object storage tools diff --git a/src/tools/storage/bucket_tools.py b/src/tools/storage/bucket_tools.py new file mode 100644 index 0000000..fc98767 --- /dev/null +++ b/src/tools/storage/bucket_tools.py @@ -0,0 +1,185 @@ +"""Bucket operations for S3-compatible object storage. + +This module provides tools for creating, deleting, listing, and checking +the existence of buckets in S3-compatible storage. +""" + +import logging +from functools import partial + +from minio.error import S3Error + +from src.humcp.decorator import tool +from src.humcp.permissions import check_permission, require_auth +from src.tools.storage.client import ( + get_allowed_buckets, + get_client, + run_sync, + validate_bucket, +) +from src.tools.storage.schemas import ( + BucketExistsData, + BucketExistsResponse, + BucketInfo, + CreateBucketData, + CreateBucketResponse, + DeleteBucketData, + DeleteBucketResponse, + ListBucketsData, + ListBucketsResponse, +) + +logger = logging.getLogger(__name__) + + +@tool() +async def list_buckets() -> ListBucketsResponse: + """List all accessible buckets in storage. + + Retrieves all buckets visible to the configured credentials. If + STORAGE_ALLOWED_BUCKETS is set, only those buckets are included in the result. + + Returns: + ListBucketsResponse: Response containing bucket list. + On success: success=True, data=ListBucketsData(buckets=[...]) + On error: success=False, error=str + """ + try: + await require_auth() + + client = get_client() + # Run sync S3 call in thread pool to avoid blocking event loop + all_buckets = await run_sync(partial(client.list_buckets)) + allowed = get_allowed_buckets() + + buckets = [] + for b in all_buckets: + if not allowed or b.name in allowed: + buckets.append( + BucketInfo( + name=b.name, + creation_date=b.creation_date.isoformat() + if b.creation_date + else None, + ) + ) + + return ListBucketsResponse(success=True, data=ListBucketsData(buckets=buckets)) + except S3Error as e: + logger.error("Failed to list buckets: %s", e) + return ListBucketsResponse(success=False, error=str(e)) + except ValueError as e: + return ListBucketsResponse(success=False, error=str(e)) + + +@tool() +async def create_bucket(bucket: str) -> CreateBucketResponse: + """Create a new bucket in storage. + + Creates a bucket with the specified name. The bucket must be in the + allowed list if STORAGE_ALLOWED_BUCKETS is configured. + + Args: + bucket: Name of the bucket to create. Must follow S3 bucket naming rules: + - 3-63 characters long + - Lowercase letters, numbers, and hyphens only + - Must start and end with a letter or number + + Returns: + CreateBucketResponse: Response indicating creation result. + On success: success=True, data=CreateBucketData(bucket=str, created=True) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return CreateBucketResponse(success=False, error=error) + + try: + await require_auth() + + client = get_client() + if await run_sync(partial(client.bucket_exists, bucket)): + return CreateBucketResponse( + success=False, error=f"Bucket '{bucket}' already exists" + ) + + await run_sync(partial(client.make_bucket, bucket)) + return CreateBucketResponse( + success=True, data=CreateBucketData(bucket=bucket, created=True) + ) + except S3Error as e: + logger.error("Failed to create bucket '%s': %s", bucket, e) + return CreateBucketResponse(success=False, error=str(e)) + except ValueError as e: + return CreateBucketResponse(success=False, error=str(e)) + + +@tool() +async def delete_bucket(bucket: str) -> DeleteBucketResponse: + """Delete an empty bucket from storage. + + Removes a bucket from the storage. The bucket must be empty before + deletion. Use list_objects to verify contents and delete_object to + remove objects first if needed. + + Args: + bucket: Name of the bucket to delete. + + Returns: + DeleteBucketResponse: Response indicating deletion result. + On success: success=True, data=DeleteBucketData(bucket=str, deleted=True) + On error: success=False, error=str + Common errors: bucket not empty, bucket does not exist + """ + if error := validate_bucket(bucket): + return DeleteBucketResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "owner") + + client = get_client() + if not await run_sync(partial(client.bucket_exists, bucket)): + return DeleteBucketResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + await run_sync(partial(client.remove_bucket, bucket)) + return DeleteBucketResponse( + success=True, data=DeleteBucketData(bucket=bucket, deleted=True) + ) + except S3Error as e: + logger.error("Failed to delete bucket '%s': %s", bucket, e) + return DeleteBucketResponse(success=False, error=str(e)) + except ValueError as e: + return DeleteBucketResponse(success=False, error=str(e)) + + +@tool() +async def bucket_exists(bucket: str) -> BucketExistsResponse: + """Check if a bucket exists in storage. + + Verifies whether a bucket with the given name exists and is accessible. + + Args: + bucket: Name of the bucket to check. + + Returns: + BucketExistsResponse: Response indicating bucket existence. + On success: success=True, data=BucketExistsData(bucket=str, exists=bool) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return BucketExistsResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + exists = await run_sync(partial(client.bucket_exists, bucket)) + return BucketExistsResponse( + success=True, data=BucketExistsData(bucket=bucket, exists=exists) + ) + except S3Error as e: + logger.error("Failed to check bucket '%s': %s", bucket, e) + return BucketExistsResponse(success=False, error=str(e)) + except ValueError as e: + return BucketExistsResponse(success=False, error=str(e)) diff --git a/src/tools/storage/client.py b/src/tools/storage/client.py new file mode 100644 index 0000000..2742df0 --- /dev/null +++ b/src/tools/storage/client.py @@ -0,0 +1,208 @@ +"""S3-compatible storage client setup and validation utilities. + +This module provides the MinIO client singleton, bucket validation, +object name validation, and local path validation for the storage tools. + +Environment Variables: + STORAGE_ENDPOINT: Storage server address (default: "minio:9000") + STORAGE_ACCESS_KEY: Access key for authentication (required) + STORAGE_SECRET_KEY: Secret key for authentication (required) + STORAGE_SECURE: Use HTTPS connection (default: "false") + STORAGE_REGION: Region for the storage service (optional, needed for AWS S3/GCS) + STORAGE_ALLOWED_BUCKETS: Comma-separated list of allowed bucket names (optional) + STORAGE_ALLOW_ABSOLUTE_PATHS: Allow absolute paths for downloads (default: "false") +""" + +import asyncio +import logging +import os +from functools import partial +from pathlib import Path + +from minio import Minio + +logger = logging.getLogger(__name__) + +# Singleton S3-compatible client +_client: Minio | None = None +_allowed_buckets: set[str] | None = None + + +async def run_sync[T](func: partial[T]) -> T: + """Run a synchronous function in a thread pool to avoid blocking the event loop. + + The S3 client uses synchronous HTTP calls. This helper runs those calls + in a thread pool executor to maintain async compatibility. + + Args: + func: A partial function wrapping the sync call. + + Returns: + The result of the synchronous function. + + Example: + result = await run_sync(partial(client.list_buckets)) + """ + return await asyncio.to_thread(func) + + +def get_client() -> Minio: + """Get or create the S3-compatible client singleton. + + Creates an S3-compatible client using environment variables for configuration. + The client is cached as a singleton for reuse across requests. + + Returns: + Minio: Configured S3-compatible client instance. + + Raises: + ValueError: If STORAGE_ACCESS_KEY or STORAGE_SECRET_KEY is not set. + """ + global _client + if _client is None: + endpoint = os.getenv("STORAGE_ENDPOINT", "minio:9000") + access_key = os.getenv("STORAGE_ACCESS_KEY") + secret_key = os.getenv("STORAGE_SECRET_KEY") + secure = os.getenv("STORAGE_SECURE", "false").lower() == "true" + region = os.getenv("STORAGE_REGION") or None + + if not access_key or not secret_key: + raise ValueError("STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY must be set") + + _client = Minio( + endpoint=endpoint, + access_key=access_key, + secret_key=secret_key, + secure=secure, + region=region, + ) + return _client + + +def get_allowed_buckets() -> set[str]: + """Get the set of allowed bucket names from environment. + + Parses STORAGE_ALLOWED_BUCKETS environment variable as a comma-separated + list of bucket names. If not set, returns an empty set (all buckets allowed). + + Returns: + set[str]: Set of allowed bucket names, or empty set if no restrictions. + """ + global _allowed_buckets + if _allowed_buckets is None: + buckets_str = os.getenv("STORAGE_ALLOWED_BUCKETS", "") + _allowed_buckets = {b.strip() for b in buckets_str.split(",") if b.strip()} + return _allowed_buckets + + +def reset_client() -> None: + """Reset the client singleton and cached configuration. + + Clears the cached S3 client and allowed buckets set. Primarily used + for testing to ensure a fresh client state between tests. + """ + global _client, _allowed_buckets + _client = None + _allowed_buckets = None + + +def validate_bucket(bucket: str) -> str | None: + """Validate that a bucket is in the allowed list. + + Checks if the given bucket name is permitted according to the + STORAGE_ALLOWED_BUCKETS configuration. If no allowlist is configured, + all buckets are permitted. + + Args: + bucket: Name of the bucket to validate. + + Returns: + str | None: Error message if bucket is not allowed, None if permitted. + """ + allowed = get_allowed_buckets() + if allowed and bucket not in allowed: + return f"Bucket '{bucket}' not in allowed list. Allowed: {sorted(allowed)}" + return None + + +def validate_object_name(object_name: str) -> str | None: + """Validate that an object name is safe and well-formed. + + Checks for path traversal attempts and invalid characters in object names. + While S3 technically allows almost any characters in object names, + we restrict to safe patterns for security. + + Args: + object_name: Object key/path to validate. + + Returns: + str | None: Error message if validation fails, None if valid. + """ + if not object_name: + return "Object name cannot be empty" + + # Reject path traversal patterns + if ".." in object_name: + return "Object name cannot contain '..'" + + # Reject absolute paths (starting with /) + if object_name.startswith("/"): + return "Object name cannot start with '/'" + + # Reject names with null bytes + if "\x00" in object_name: + return "Object name cannot contain null bytes" + + # Limit length (S3 limit is 1024 bytes) + if len(object_name.encode("utf-8")) > 1024: + return "Object name exceeds maximum length of 1024 bytes" + + return None + + +def _allow_absolute_paths() -> bool: + """Check if absolute paths are allowed for downloads.""" + return os.getenv("STORAGE_ALLOW_ABSOLUTE_PATHS", "").lower() == "true" + + +def validate_local_path(file_path: str) -> str | None: + """Validate that a local file path is safe for writing. + + By default, paths must be within the current working directory to prevent + directory traversal attacks. Set STORAGE_ALLOW_ABSOLUTE_PATHS=true to allow + writing to any path (use with caution). + + Args: + file_path: Local file path to validate. + + Returns: + str | None: Error message if validation fails, None if valid. + """ + if not file_path: + return "File path cannot be empty" + + path = Path(file_path) + + # Reject path traversal patterns + if ".." in str(path): + return "File path cannot contain '..'" + + # If absolute paths are allowed, skip containment check + if _allow_absolute_paths(): + return None + + # Resolve both paths to compare + try: + resolved = path.resolve() + base_dir = Path.cwd().resolve() + + # Check if path is within base directory + resolved.relative_to(base_dir) + return None + except ValueError: + return ( + f"Path '{file_path}' is outside allowed directory '{base_dir}'. " + "Set STORAGE_ALLOW_ABSOLUTE_PATHS=true to allow absolute paths." + ) + except OSError as e: + return f"Invalid path: {e}" diff --git a/src/tools/storage/metadata_tools.py b/src/tools/storage/metadata_tools.py new file mode 100644 index 0000000..b7fc080 --- /dev/null +++ b/src/tools/storage/metadata_tools.py @@ -0,0 +1,161 @@ +"""Metadata and utility operations for S3-compatible object storage. + +This module provides tools for retrieving object metadata and generating +presigned URLs for temporary object access. +""" + +import logging +from datetime import timedelta +from functools import partial + +from minio.error import S3Error + +from src.humcp.decorator import tool +from src.humcp.permissions import check_permission +from src.tools.storage.client import ( + get_client, + run_sync, + validate_bucket, + validate_object_name, +) +from src.tools.storage.schemas import ( + GetObjectMetadataData, + GetObjectMetadataResponse, + GetPresignedUrlData, + GetPresignedUrlResponse, +) + +logger = logging.getLogger(__name__) + + +@tool() +async def get_object_metadata( + bucket: str, object_name: str +) -> GetObjectMetadataResponse: + """Get metadata for an object without downloading its content. + + Retrieves object metadata including size, content type, modification time, + ETag, and any custom user-defined metadata. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to get metadata for. + + Returns: + GetObjectMetadataResponse: Response containing object metadata. + On success: success=True, data=GetObjectMetadataData(bucket, object_name, size, last_modified, etag, content_type, version_id, metadata) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return GetObjectMetadataResponse(success=False, error=error) + if error := validate_object_name(object_name): + return GetObjectMetadataResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + stat = await run_sync(partial(client.stat_object, bucket, object_name)) + + return GetObjectMetadataResponse( + success=True, + data=GetObjectMetadataData( + bucket=bucket, + object_name=object_name, + size=stat.size or 0, + last_modified=stat.last_modified.isoformat() + if stat.last_modified + else None, + etag=stat.etag or "", + content_type=stat.content_type or "", + version_id=stat.version_id, + metadata=dict(stat.metadata) if stat.metadata else {}, + ), + ) + except S3Error as e: + if e.code == "NoSuchKey": + return GetObjectMetadataResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + logger.error("Failed to get metadata for '%s/%s': %s", bucket, object_name, e) + return GetObjectMetadataResponse(success=False, error=str(e)) + except ValueError as e: + return GetObjectMetadataResponse(success=False, error=str(e)) + + +@tool() +async def get_presigned_url( + bucket: str, + object_name: str, + expires_hours: int = 1, +) -> GetPresignedUrlResponse: + """Generate a presigned URL for temporary object access. + + Creates a time-limited URL that allows downloading an object without + authentication. Useful for sharing files temporarily or generating + download links for end users. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to generate URL for. + expires_hours: URL validity period in hours. Must be between 1 and 168 + (7 days). Defaults to 1 hour. + + Returns: + GetPresignedUrlResponse: Response containing the presigned URL. + On success: success=True, data=GetPresignedUrlData(bucket, object_name, url, expires_in_hours) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return GetPresignedUrlResponse(success=False, error=error) + if error := validate_object_name(object_name): + return GetPresignedUrlResponse(success=False, error=error) + + if expires_hours < 1 or expires_hours > 168: # Max 7 days + return GetPresignedUrlResponse( + success=False, + error="expires_hours must be between 1 and 168 (7 days)", + ) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + + # Verify object exists + try: + await run_sync(partial(client.stat_object, bucket, object_name)) + except S3Error as e: + if e.code == "NoSuchKey": + return GetPresignedUrlResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + raise + + url = await run_sync( + partial( + client.presigned_get_object, + bucket, + object_name, + expires=timedelta(hours=expires_hours), + ) + ) + + return GetPresignedUrlResponse( + success=True, + data=GetPresignedUrlData( + bucket=bucket, + object_name=object_name, + url=url, + expires_in_hours=expires_hours, + ), + ) + except S3Error as e: + logger.error( + "Failed to generate presigned URL for '%s/%s': %s", bucket, object_name, e + ) + return GetPresignedUrlResponse(success=False, error=str(e)) + except ValueError as e: + return GetPresignedUrlResponse(success=False, error=str(e)) diff --git a/src/tools/storage/object_tools.py b/src/tools/storage/object_tools.py new file mode 100644 index 0000000..76e59be --- /dev/null +++ b/src/tools/storage/object_tools.py @@ -0,0 +1,568 @@ +"""Object operations for S3-compatible object storage. + +This module provides tools for listing, uploading, downloading, deleting, +and copying objects in S3-compatible storage buckets. +""" + +import base64 +import io +import logging +from functools import partial +from pathlib import Path + +from minio.commonconfig import CopySource +from minio.error import S3Error + +from src.humcp.decorator import tool +from src.humcp.permissions import check_permission +from src.tools.storage.client import ( + get_client, + run_sync, + validate_bucket, + validate_local_path, + validate_object_name, +) +from src.tools.storage.schemas import ( + CopyObjectData, + CopyObjectResponse, + DeleteObjectData, + DeleteObjectResponse, + DownloadContentData, + DownloadContentResponse, + DownloadToPathData, + DownloadToPathResponse, + ListObjectsData, + ListObjectsResponse, + ObjectInfo, + UploadContentData, + UploadContentResponse, + UploadFromPathData, + UploadFromPathResponse, +) + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Object Listing +# ============================================================================= + + +@tool() +async def list_objects( + bucket: str, prefix: str = "", recursive: bool = True +) -> ListObjectsResponse: + """List objects in a bucket with optional prefix filtering. + + Retrieves a list of objects in the specified bucket. Supports prefix + filtering for listing objects in a specific "directory" and recursive + listing to include objects in subdirectories. + + Args: + bucket: Name of the bucket to list objects from. + prefix: Filter objects by key prefix (e.g., "documents/2024/"). + Defaults to "" (no filter). + recursive: If True, list all objects including those in subdirectories. + If False, list only objects at the current level. Defaults to True. + + Returns: + ListObjectsResponse: Response containing object list. + On success: success=True, data=ListObjectsData(bucket, prefix, recursive, objects, count) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return ListObjectsResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + if not await run_sync(partial(client.bucket_exists, bucket)): + return ListObjectsResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # list_objects returns an iterator, so we need to collect in thread pool + def _list_objects() -> list[ObjectInfo]: + result = [] + for obj in client.list_objects(bucket, prefix=prefix, recursive=recursive): + result.append( + ObjectInfo( + name=obj.object_name, + size=obj.size, + last_modified=obj.last_modified.isoformat() + if obj.last_modified + else None, + etag=obj.etag, + is_dir=obj.is_dir, + ) + ) + return result + + objects = await run_sync(partial(_list_objects)) + + return ListObjectsResponse( + success=True, + data=ListObjectsData( + bucket=bucket, + prefix=prefix, + recursive=recursive, + objects=objects, + count=len(objects), + ), + ) + except S3Error as e: + logger.error("Failed to list objects in '%s': %s", bucket, e) + return ListObjectsResponse(success=False, error=str(e)) + except ValueError as e: + return ListObjectsResponse(success=False, error=str(e)) + + +# ============================================================================= +# Upload Tools +# ============================================================================= + + +@tool() +async def upload_content( + bucket: str, + object_name: str, + content_base64: str, + content_type: str = "application/octet-stream", +) -> UploadContentResponse: + """Upload base64-encoded content to storage. + + Uploads data provided as a base64-encoded string to the specified + bucket and object path. Best suited for small files (< 10MB) where + content is already in memory. + + Args: + bucket: Name of the destination bucket. + object_name: Object key/path in the bucket (e.g., "documents/report.pdf"). + content_base64: Base64-encoded file content. + content_type: MIME type of the content (e.g., "application/pdf", "image/jpeg"). + Defaults to "application/octet-stream". + + Returns: + UploadContentResponse: Response containing upload result. + On success: success=True, data=UploadContentData(bucket, object_name, size, etag, version_id) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return UploadContentResponse(success=False, error=error) + if error := validate_object_name(object_name): + return UploadContentResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "editor") + + client = get_client() + + # Ensure bucket exists + if not await run_sync(partial(client.bucket_exists, bucket)): + return UploadContentResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # Decode base64 content + try: + content = base64.b64decode(content_base64) + except Exception as e: + return UploadContentResponse( + success=False, error=f"Invalid base64 content: {e}" + ) + + # Upload + data = io.BytesIO(content) + result = await run_sync( + partial( + client.put_object, + bucket, + object_name, + data, + length=len(content), + content_type=content_type, + ) + ) + + return UploadContentResponse( + success=True, + data=UploadContentData( + bucket=bucket, + object_name=object_name, + size=len(content), + etag=result.etag or "", + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error("Failed to upload to '%s/%s': %s", bucket, object_name, e) + return UploadContentResponse(success=False, error=str(e)) + except ValueError as e: + return UploadContentResponse(success=False, error=str(e)) + + +@tool() +async def upload_from_path( + bucket: str, + object_name: str, + file_path: str, + content_type: str = "application/octet-stream", +) -> UploadFromPathResponse: + """Upload a file from the local filesystem to storage. + + Uploads a file directly from disk to the specified bucket and object path. + Suitable for large files as content is streamed rather than loaded into memory. + + Args: + bucket: Name of the destination bucket. + object_name: Object key/path in the bucket (e.g., "backups/data.zip"). + file_path: Absolute or relative path to the local file. + content_type: MIME type of the content. Defaults to "application/octet-stream". + + Returns: + UploadFromPathResponse: Response containing upload result. + On success: success=True, data=UploadFromPathData(bucket, object_name, file_path, size, etag, version_id) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return UploadFromPathResponse(success=False, error=error) + if error := validate_object_name(object_name): + return UploadFromPathResponse(success=False, error=error) + if error := validate_local_path(file_path): + return UploadFromPathResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "editor") + + client = get_client() + + # Ensure bucket exists + if not await run_sync(partial(client.bucket_exists, bucket)): + return UploadFromPathResponse( + success=False, error=f"Bucket '{bucket}' does not exist" + ) + + # Validate file path + path = Path(file_path) + if not path.exists(): + return UploadFromPathResponse( + success=False, error=f"File not found: {file_path}" + ) + if not path.is_file(): + return UploadFromPathResponse( + success=False, error=f"Path is not a file: {file_path}" + ) + + # Upload + result = await run_sync( + partial( + client.fput_object, + bucket, + object_name, + file_path, + content_type=content_type, + ) + ) + + return UploadFromPathResponse( + success=True, + data=UploadFromPathData( + bucket=bucket, + object_name=object_name, + file_path=file_path, + size=path.stat().st_size, + etag=result.etag or "", + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error( + "Failed to upload '%s' to '%s/%s': %s", file_path, bucket, object_name, e + ) + return UploadFromPathResponse(success=False, error=str(e)) + except ValueError as e: + return UploadFromPathResponse(success=False, error=str(e)) + + +# ============================================================================= +# Download Tools +# ============================================================================= + + +@tool() +async def download_content(bucket: str, object_name: str) -> DownloadContentResponse: + """Download an object and return its content as a base64-encoded string. + + Retrieves the complete content of an object and encodes it as base64. + Best suited for small files (< 10MB) where content is needed in memory. + For large files, use download_to_path instead. + + Args: + bucket: Name of the source bucket. + object_name: Object key/path to download. + + Returns: + DownloadContentResponse: Response containing the object content. + On success: success=True, data=DownloadContentData(bucket, object_name, content_base64, size, content_type, etag) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DownloadContentResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DownloadContentResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + + # Get object content and metadata in thread pool + def _download() -> tuple[bytes, str | None, str | None]: + response = client.get_object(bucket, object_name) + try: + content = response.read() + finally: + response.close() + response.release_conn() + stat = client.stat_object(bucket, object_name) + return content, stat.content_type, stat.etag + + content, content_type_val, etag = await run_sync(partial(_download)) + + return DownloadContentResponse( + success=True, + data=DownloadContentData( + bucket=bucket, + object_name=object_name, + content_base64=base64.b64encode(content).decode("utf-8"), + size=len(content), + content_type=content_type_val or "", + etag=etag or "", + ), + ) + except S3Error as e: + logger.error("Failed to download '%s/%s': %s", bucket, object_name, e) + return DownloadContentResponse(success=False, error=str(e)) + except ValueError as e: + return DownloadContentResponse(success=False, error=str(e)) + + +@tool() +async def download_to_path( + bucket: str, object_name: str, file_path: str +) -> DownloadToPathResponse: + """Download an object to a local file. + + Downloads an object from storage and saves it to the specified path. + Parent directories are created automatically if they don't exist. + Suitable for large files as content is streamed directly to disk. + + Args: + bucket: Name of the source bucket. + object_name: Object key/path to download. + file_path: Destination path for the downloaded file. + + Returns: + DownloadToPathResponse: Response confirming the download. + On success: success=True, data=DownloadToPathData(bucket, object_name, file_path, size) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DownloadToPathResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DownloadToPathResponse(success=False, error=error) + if error := validate_local_path(file_path): + return DownloadToPathResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "viewer") + + client = get_client() + + # Ensure parent directory exists + path = Path(file_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + return DownloadToPathResponse( + success=False, + error=f"Permission denied creating directory: {path.parent}", + ) + except OSError as e: + return DownloadToPathResponse( + success=False, error=f"Failed to create directory: {e}" + ) + + # Download + await run_sync(partial(client.fget_object, bucket, object_name, file_path)) + + # Get file size + size = path.stat().st_size + + return DownloadToPathResponse( + success=True, + data=DownloadToPathData( + bucket=bucket, + object_name=object_name, + file_path=file_path, + size=size, + ), + ) + except S3Error as e: + logger.error( + "Failed to download '%s/%s' to '%s': %s", bucket, object_name, file_path, e + ) + return DownloadToPathResponse(success=False, error=str(e)) + except ValueError as e: + return DownloadToPathResponse(success=False, error=str(e)) + + +# ============================================================================= +# Delete & Copy +# ============================================================================= + + +@tool() +async def delete_object(bucket: str, object_name: str) -> DeleteObjectResponse: + """Delete an object from a bucket. + + Permanently removes an object from the specified bucket. This operation + cannot be undone unless versioning is enabled on the bucket. + + Args: + bucket: Name of the bucket containing the object. + object_name: Object key/path to delete. + + Returns: + DeleteObjectResponse: Response confirming the deletion. + On success: success=True, data=DeleteObjectData(bucket, object_name, deleted=True) + On error: success=False, error=str + """ + if error := validate_bucket(bucket): + return DeleteObjectResponse(success=False, error=error) + if error := validate_object_name(object_name): + return DeleteObjectResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", bucket, "editor") + + client = get_client() + + # Check if object exists first + try: + await run_sync(partial(client.stat_object, bucket, object_name)) + except S3Error as e: + if e.code == "NoSuchKey": + return DeleteObjectResponse( + success=False, + error=f"Object '{object_name}' not found in bucket '{bucket}'", + ) + raise + + await run_sync(partial(client.remove_object, bucket, object_name)) + return DeleteObjectResponse( + success=True, + data=DeleteObjectData( + bucket=bucket, + object_name=object_name, + deleted=True, + ), + ) + except S3Error as e: + logger.error("Failed to delete '%s/%s': %s", bucket, object_name, e) + return DeleteObjectResponse(success=False, error=str(e)) + except ValueError as e: + return DeleteObjectResponse(success=False, error=str(e)) + + +@tool() +async def copy_object( + source_bucket: str, + source_object: str, + dest_bucket: str, + dest_object: str, +) -> CopyObjectResponse: + """Copy an object from one location to another. + + Copies an object between buckets or within the same bucket. Both the + source and destination buckets must be in the allowed list if + STORAGE_ALLOWED_BUCKETS is configured. The copy is performed server-side. + + Args: + source_bucket: Name of the bucket containing the source object. + source_object: Object key/path of the source object. + dest_bucket: Name of the destination bucket. + dest_object: Object key/path for the copied object. + + Returns: + CopyObjectResponse: Response confirming the copy operation. + On success: success=True, data=CopyObjectData(source, destination, etag, version_id) + On error: success=False, error=str + """ + # Validate both buckets + if error := validate_bucket(source_bucket): + return CopyObjectResponse(success=False, error=error) + if error := validate_bucket(dest_bucket): + return CopyObjectResponse(success=False, error=error) + # Validate both object names + if error := validate_object_name(source_object): + return CopyObjectResponse(success=False, error=error) + if error := validate_object_name(dest_object): + return CopyObjectResponse(success=False, error=error) + + try: + await check_permission("storage_bucket", source_bucket, "viewer") + await check_permission("storage_bucket", dest_bucket, "editor") + + client = get_client() + + # Verify source exists + try: + await run_sync(partial(client.stat_object, source_bucket, source_object)) + except S3Error as e: + if e.code == "NoSuchKey": + return CopyObjectResponse( + success=False, + error=f"Source object '{source_object}' not found in bucket '{source_bucket}'", + ) + raise + + # Ensure destination bucket exists + if not await run_sync(partial(client.bucket_exists, dest_bucket)): + return CopyObjectResponse( + success=False, + error=f"Destination bucket '{dest_bucket}' does not exist", + ) + + # Copy object + result = await run_sync( + partial( + client.copy_object, + dest_bucket, + dest_object, + CopySource(source_bucket, source_object), + ) + ) + + return CopyObjectResponse( + success=True, + data=CopyObjectData( + source=f"{source_bucket}/{source_object}", + destination=f"{dest_bucket}/{dest_object}", + etag=result.etag or "", + version_id=result.version_id, + ), + ) + except S3Error as e: + logger.error( + "Failed to copy '%s/%s' to '%s/%s': %s", + source_bucket, + source_object, + dest_bucket, + dest_object, + e, + ) + return CopyObjectResponse(success=False, error=str(e)) + except ValueError as e: + return CopyObjectResponse(success=False, error=str(e)) diff --git a/src/tools/storage/schemas.py b/src/tools/storage/schemas.py new file mode 100644 index 0000000..a6d06e7 --- /dev/null +++ b/src/tools/storage/schemas.py @@ -0,0 +1,245 @@ +"""Pydantic output schemas for S3-compatible storage tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Data Models for Response Fields +# ============================================================================= + + +class BucketInfo(BaseModel): + """Information about a bucket.""" + + name: str = Field(..., description="Bucket name") + creation_date: str | None = Field( + None, description="Bucket creation date in ISO format" + ) + + +class ObjectInfo(BaseModel): + """Information about an object in a bucket.""" + + name: str = Field(..., description="Object name/path") + size: int | None = Field(None, description="Object size in bytes") + last_modified: str | None = Field( + None, description="Last modification date in ISO format" + ) + etag: str | None = Field(None, description="Object ETag") + is_dir: bool = Field(False, description="Whether this is a directory marker") + + +# ============================================================================= +# Output Data Schemas (the 'data' field content) +# ============================================================================= + + +class ListBucketsData(BaseModel): + """Output data for list_buckets tool.""" + + buckets: list[BucketInfo] = Field(..., description="List of accessible buckets") + + +class CreateBucketData(BaseModel): + """Output data for create_bucket tool.""" + + bucket: str = Field(..., description="Name of the created bucket") + created: bool = Field(True, description="Whether the bucket was created") + + +class DeleteBucketData(BaseModel): + """Output data for delete_bucket tool.""" + + bucket: str = Field(..., description="Name of the deleted bucket") + deleted: bool = Field(True, description="Whether the bucket was deleted") + + +class BucketExistsData(BaseModel): + """Output data for bucket_exists tool.""" + + bucket: str = Field(..., description="Bucket name that was checked") + exists: bool = Field(..., description="Whether the bucket exists") + + +class ListObjectsData(BaseModel): + """Output data for list_objects tool.""" + + bucket: str = Field(..., description="Bucket name") + prefix: str = Field("", description="Prefix filter used") + recursive: bool = Field(True, description="Whether listing was recursive") + objects: list[ObjectInfo] = Field(..., description="List of objects") + count: int = Field(..., description="Number of objects returned") + + +class UploadContentData(BaseModel): + """Output data for upload_content tool.""" + + bucket: str = Field(..., description="Destination bucket name") + object_name: str = Field(..., description="Object key/path") + size: int = Field(..., description="Size of uploaded content in bytes") + etag: str = Field(default="", description="ETag of the uploaded object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class UploadFromPathData(BaseModel): + """Output data for upload_from_path tool.""" + + bucket: str = Field(..., description="Destination bucket name") + object_name: str = Field(..., description="Object key/path") + file_path: str = Field(..., description="Source file path") + size: int = Field(..., description="Size of uploaded file in bytes") + etag: str = Field(default="", description="ETag of the uploaded object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class DownloadContentData(BaseModel): + """Output data for download_content tool.""" + + bucket: str = Field(..., description="Source bucket name") + object_name: str = Field(..., description="Object key/path") + content_base64: str = Field(..., description="Base64-encoded file content") + size: int = Field(..., description="Size of content in bytes") + content_type: str = Field(..., description="MIME type of the content") + etag: str = Field(..., description="ETag of the object") + + +class DownloadToPathData(BaseModel): + """Output data for download_to_path tool.""" + + bucket: str = Field(..., description="Source bucket name") + object_name: str = Field(..., description="Object key/path") + file_path: str = Field(..., description="Destination file path") + size: int = Field(..., description="Size of downloaded file in bytes") + + +class DeleteObjectData(BaseModel): + """Output data for delete_object tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Deleted object key/path") + deleted: bool = Field(True, description="Whether the object was deleted") + + +class CopyObjectData(BaseModel): + """Output data for copy_object tool.""" + + source: str = Field(..., description="Source path (bucket/object)") + destination: str = Field(..., description="Destination path (bucket/object)") + etag: str = Field(default="", description="ETag of the copied object") + version_id: str | None = Field( + None, description="Version ID if versioning is enabled" + ) + + +class GetObjectMetadataData(BaseModel): + """Output data for get_object_metadata tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Object key/path") + size: int = Field(default=0, description="Object size in bytes") + last_modified: str | None = Field( + None, description="Last modification date in ISO format" + ) + etag: str = Field(default="", description="Object ETag") + content_type: str = Field(default="", description="MIME type of the content") + version_id: str | None = Field(None, description="Version ID") + metadata: dict[str, str] = Field( + default_factory=dict, description="Custom user metadata" + ) + + +class GetPresignedUrlData(BaseModel): + """Output data for get_presigned_url tool.""" + + bucket: str = Field(..., description="Bucket name") + object_name: str = Field(..., description="Object key/path") + url: str = Field(..., description="Presigned URL for downloading the object") + expires_in_hours: int = Field(..., description="URL validity period in hours") + + +# ============================================================================= +# Full Response Schemas (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ListBucketsResponse(ToolResponse[ListBucketsData]): + """Response schema for list_buckets tool.""" + + pass + + +class CreateBucketResponse(ToolResponse[CreateBucketData]): + """Response schema for create_bucket tool.""" + + pass + + +class DeleteBucketResponse(ToolResponse[DeleteBucketData]): + """Response schema for delete_bucket tool.""" + + pass + + +class BucketExistsResponse(ToolResponse[BucketExistsData]): + """Response schema for bucket_exists tool.""" + + pass + + +class ListObjectsResponse(ToolResponse[ListObjectsData]): + """Response schema for list_objects tool.""" + + pass + + +class UploadContentResponse(ToolResponse[UploadContentData]): + """Response schema for upload_content tool.""" + + pass + + +class UploadFromPathResponse(ToolResponse[UploadFromPathData]): + """Response schema for upload_from_path tool.""" + + pass + + +class DownloadContentResponse(ToolResponse[DownloadContentData]): + """Response schema for download_content tool.""" + + pass + + +class DownloadToPathResponse(ToolResponse[DownloadToPathData]): + """Response schema for download_to_path tool.""" + + pass + + +class DeleteObjectResponse(ToolResponse[DeleteObjectData]): + """Response schema for delete_object tool.""" + + pass + + +class CopyObjectResponse(ToolResponse[CopyObjectData]): + """Response schema for copy_object tool.""" + + pass + + +class GetObjectMetadataResponse(ToolResponse[GetObjectMetadataData]): + """Response schema for get_object_metadata tool.""" + + pass + + +class GetPresignedUrlResponse(ToolResponse[GetPresignedUrlData]): + """Response schema for get_presigned_url tool.""" + + pass diff --git a/src/tools/storage/tools.py b/src/tools/storage/tools.py new file mode 100644 index 0000000..704f96f --- /dev/null +++ b/src/tools/storage/tools.py @@ -0,0 +1,87 @@ +"""S3-compatible object storage tools. + +This module re-exports all storage tools and client utilities from their +respective sub-modules for backward compatibility. New code should import +directly from the specific sub-modules: + +- ``src.tools.storage.client`` - MinIO client setup and validation +- ``src.tools.storage.bucket_tools`` - Bucket operations +- ``src.tools.storage.object_tools`` - Object operations (list, upload, download, delete, copy) +- ``src.tools.storage.metadata_tools`` - Metadata and presigned URL operations + +Environment Variables: + STORAGE_ENDPOINT: Storage server address (default: "minio:9000") + STORAGE_ACCESS_KEY: Access key for authentication (required) + STORAGE_SECRET_KEY: Secret key for authentication (required) + STORAGE_SECURE: Use HTTPS connection (default: "false") + STORAGE_REGION: Region for the storage service (optional, needed for AWS S3/GCS) + STORAGE_ALLOWED_BUCKETS: Comma-separated list of allowed bucket names (optional) + +Example: + >>> result = await list_buckets() + >>> if result.success: + ... for bucket in result.data.buckets: + ... print(bucket.name) +""" + +# Client utilities +# Bucket operations +from src.tools.storage.bucket_tools import ( + bucket_exists, + create_bucket, + delete_bucket, + list_buckets, +) +from src.tools.storage.client import ( + get_allowed_buckets, + get_client, + reset_client, + run_sync, + validate_bucket, + validate_local_path, + validate_object_name, +) + +# Metadata operations +from src.tools.storage.metadata_tools import ( + get_object_metadata, + get_presigned_url, +) + +# Object operations +from src.tools.storage.object_tools import ( + copy_object, + delete_object, + download_content, + download_to_path, + list_objects, + upload_content, + upload_from_path, +) + +__all__ = [ + # Client utilities + "get_allowed_buckets", + "get_client", + "reset_client", + "run_sync", + "validate_bucket", + "validate_local_path", + "validate_object_name", + # Bucket operations + "bucket_exists", + "create_bucket", + "delete_bucket", + "list_buckets", + # Object operations + "copy_object", + "delete_object", + "download_content", + "download_to_path", + "list_objects", + "upload_content", + "upload_from_path", + # Metadata operations + "get_object_metadata", + "get_presigned_url", +] diff --git a/src/tools/weather/SKILL.md b/src/tools/weather/SKILL.md new file mode 100644 index 0000000..929ebb3 --- /dev/null +++ b/src/tools/weather/SKILL.md @@ -0,0 +1,82 @@ +--- +name: weather-data +description: Fetches current weather conditions and forecasts using the OpenWeatherMap API. Use when the user asks about weather, temperature, or forecasts for any city. +--- + +# Weather Tools + +Tools for fetching weather data from the OpenWeatherMap API. + +## Requirements + +Set environment variable: +- `OPENWEATHER_API_KEY`: Your OpenWeatherMap API key (free tier supported) + +## Current Weather + +```python +result = await openweather_get_current( + city="London", + units="metric" +) +``` + +### Response format + +```json +{ + "success": true, + "data": { + "city": "London", + "country": "GB", + "temperature": 15.2, + "feels_like": 14.1, + "humidity": 72, + "pressure": 1013, + "wind_speed": 3.6, + "conditions": [ + { + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "units": "metric" + } +} +``` + +## Weather Forecast + +```python +result = await openweather_get_forecast( + city="New York", + days=3, + units="imperial" +) +``` + +Returns 3-hour interval forecasts for the specified number of days (max 5). + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| city | str | City name (required) | +| days | int | Forecast days, 1-5 (default: 5) | +| units | str | "metric", "imperial", or "standard" | + +## Units + +| Value | Temperature | Wind Speed | +|-------|-------------|------------| +| metric | Celsius | m/s | +| imperial | Fahrenheit | mph | +| standard | Kelvin | m/s | + +## When to Use + +- Checking current weather for a city +- Planning activities based on weather forecasts +- Comparing weather across different locations +- Getting temperature, humidity, and wind data diff --git a/src/tools/weather/__init__.py b/src/tools/weather/__init__.py new file mode 100644 index 0000000..5feded8 --- /dev/null +++ b/src/tools/weather/__init__.py @@ -0,0 +1 @@ +# Weather tools (OpenWeatherMap) diff --git a/src/tools/weather/openweather.py b/src/tools/weather/openweather.py new file mode 100644 index 0000000..14be243 --- /dev/null +++ b/src/tools/weather/openweather.py @@ -0,0 +1,432 @@ +"""OpenWeatherMap tools for current weather, forecasts, air pollution, and geocoding. + +Wraps the OpenWeatherMap API for weather data retrieval, 5-day forecasts, +air quality information, and city-to-coordinate geocoding. + +Environment variables: + OPENWEATHER_API_KEY: API key for OpenWeatherMap (free tier supported). +""" + +from __future__ import annotations + +import logging + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.weather.schemas import ( + AirPollutionComponent, + AirPollutionData, + AirPollutionResponse, + CurrentWeatherData, + CurrentWeatherResponse, + ForecastEntry, + ForecastWeatherData, + ForecastWeatherResponse, + GeocodingData, + GeocodingResponse, + GeocodingResult, + WeatherCondition, +) + +logger = logging.getLogger("humcp.tools.openweather") + +_OWM_BASE = "https://api.openweathermap.org/data/2.5" +_GEO_BASE = "https://api.openweathermap.org/geo/1.0" + +_VALID_UNITS = {"metric", "imperial", "standard"} + + +async def _resolve_api_key() -> str | None: + """Return the OpenWeatherMap API key via credential resolution.""" + return await resolve_credential("OPENWEATHER_API_KEY") + + +async def _geocode_city( + client: httpx.AsyncClient, city: str, api_key: str +) -> dict | None: + """Geocode a city name to lat/lon using the OpenWeatherMap Geocoding API. + + Returns the first result as a dict with 'lat', 'lon', 'name', 'country', + or None if nothing was found. + """ + params = {"q": city, "limit": 1, "appid": api_key} + response = await client.get(f"{_GEO_BASE}/direct", params=params) # type: ignore[arg-type] + response.raise_for_status() + results = response.json() + if not results: + return None + return { + "lat": results[0]["lat"], + "lon": results[0]["lon"], + "name": results[0].get("name", city), + "country": results[0].get("country", ""), + "state": results[0].get("state"), + } + + +def _parse_conditions(weather_list: list[dict]) -> list[WeatherCondition]: + """Parse a list of weather condition dicts from the OWM API response.""" + return [ + WeatherCondition( + main=w.get("main", ""), + description=w.get("description", ""), + icon=w.get("icon", ""), + ) + for w in weather_list + ] + + +@tool() +async def openweather_get_current( + city: str, + units: str = "metric", +) -> CurrentWeatherResponse: + """Get current weather data for a city. + + Returns temperature, humidity, pressure, wind, visibility, cloud cover, + sunrise/sunset times, and weather conditions. + + Args: + city: City name, optionally with country code (e.g. "London", + "New York", "Tokyo", "Paris,FR"). + units: Units of measurement. One of "metric" (Celsius, m/s), + "imperial" (Fahrenheit, mph), or "standard" (Kelvin, m/s). + Default is "metric". + + Returns: + Current weather data including temperature, wind, humidity, and conditions. + """ + try: + api_key = await _resolve_api_key() + if not api_key: + return CurrentWeatherResponse( + success=False, + error="OpenWeatherMap API not configured. Set OPENWEATHER_API_KEY environment variable.", + ) + + if units not in _VALID_UNITS: + return CurrentWeatherResponse( + success=False, + error=f"Invalid units '{units}'. Must be one of: {', '.join(sorted(_VALID_UNITS))}", + ) + + logger.info("OpenWeather current city=%r units=%s", city, units) + + async with httpx.AsyncClient(timeout=15) as client: + geo = await _geocode_city(client, city, api_key) + if geo is None: + return CurrentWeatherResponse( + success=False, + error=f"City '{city}' not found.", + ) + + params = { + "lat": geo["lat"], + "lon": geo["lon"], + "units": units, + "appid": api_key, + } + response = await client.get(f"{_OWM_BASE}/weather", params=params) + response.raise_for_status() + data = response.json() + + main = data.get("main", {}) + wind = data.get("wind", {}) + sys_data = data.get("sys", {}) + + return CurrentWeatherResponse( + success=True, + data=CurrentWeatherData( + city=geo["name"], + country=geo["country"], + temperature=main.get("temp", 0.0), + feels_like=main.get("feels_like", 0.0), + temp_min=main.get("temp_min", 0.0), + temp_max=main.get("temp_max", 0.0), + humidity=main.get("humidity", 0), + pressure=main.get("pressure", 0), + wind_speed=wind.get("speed", 0.0), + wind_deg=wind.get("deg"), + wind_gust=wind.get("gust"), + visibility=data.get("visibility"), + clouds=data.get("clouds", {}).get("all"), + conditions=_parse_conditions(data.get("weather", [])), + units=units, + sunrise=sys_data.get("sunrise"), + sunset=sys_data.get("sunset"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("OpenWeather current HTTP error") + return CurrentWeatherResponse( + success=False, + error=f"OpenWeatherMap API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("OpenWeather current failed") + return CurrentWeatherResponse( + success=False, error=f"OpenWeather current weather failed: {e}" + ) + + +@tool() +async def openweather_get_forecast( + city: str, + days: int = 5, + units: str = "metric", +) -> ForecastWeatherResponse: + """Get a 5-day weather forecast for a city in 3-hour intervals. + + The OpenWeatherMap free tier provides up to 5 days of forecast data + with 3-hour granularity (up to 40 data points). + + Args: + city: City name, optionally with country code (e.g. "London", + "New York,US", "Tokyo,JP"). + days: Number of forecast days (1-5, default 5). Clamped to valid range. + units: Units of measurement. One of "metric" (Celsius, m/s), + "imperial" (Fahrenheit, mph), or "standard" (Kelvin, m/s). + Default is "metric". + + Returns: + Forecast entries with temperature, humidity, wind, precipitation + probability, and conditions. + """ + try: + api_key = await _resolve_api_key() + if not api_key: + return ForecastWeatherResponse( + success=False, + error="OpenWeatherMap API not configured. Set OPENWEATHER_API_KEY environment variable.", + ) + + if units not in _VALID_UNITS: + return ForecastWeatherResponse( + success=False, + error=f"Invalid units '{units}'. Must be one of: {', '.join(sorted(_VALID_UNITS))}", + ) + + days = max(1, min(days, 5)) + logger.info("OpenWeather forecast city=%r days=%d units=%s", city, days, units) + + async with httpx.AsyncClient(timeout=15) as client: + geo = await _geocode_city(client, city, api_key) + if geo is None: + return ForecastWeatherResponse( + success=False, + error=f"City '{city}' not found.", + ) + + params = { + "lat": geo["lat"], + "lon": geo["lon"], + "units": units, + "cnt": min(days * 8, 40), + "appid": api_key, + } + response = await client.get(f"{_OWM_BASE}/forecast", params=params) + response.raise_for_status() + data = response.json() + + forecasts: list[ForecastEntry] = [] + for entry in data.get("list", []): + entry_main = entry.get("main", {}) + entry_wind = entry.get("wind", {}) + forecasts.append( + ForecastEntry( + dt=entry.get("dt", 0), + dt_txt=entry.get("dt_txt", ""), + temperature=entry_main.get("temp", 0.0), + feels_like=entry_main.get("feels_like", 0.0), + temp_min=entry_main.get("temp_min", 0.0), + temp_max=entry_main.get("temp_max", 0.0), + humidity=entry_main.get("humidity", 0), + pressure=entry_main.get("pressure", 0), + wind_speed=entry_wind.get("speed", 0.0), + wind_deg=entry_wind.get("deg"), + pop=entry.get("pop"), + conditions=_parse_conditions(entry.get("weather", [])), + ) + ) + + logger.info("OpenWeather forecast complete entries=%d", len(forecasts)) + return ForecastWeatherResponse( + success=True, + data=ForecastWeatherData( + city=geo["name"], + country=geo["country"], + days=days, + units=units, + forecasts=forecasts, + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("OpenWeather forecast HTTP error") + return ForecastWeatherResponse( + success=False, + error=f"OpenWeatherMap API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("OpenWeather forecast failed") + return ForecastWeatherResponse( + success=False, error=f"OpenWeather forecast failed: {e}" + ) + + +@tool() +async def openweather_get_air_pollution( + city: str, +) -> AirPollutionResponse: + """Get current air pollution data for a city. + + Returns the Air Quality Index (AQI) and concentrations of pollutants + including CO, NO, NO2, O3, SO2, PM2.5, PM10, and NH3. + + The AQI scale: 1 = Good, 2 = Fair, 3 = Moderate, 4 = Poor, 5 = Very Poor. + + Args: + city: City name (e.g. "London", "Beijing", "Los Angeles"). + + Returns: + Air quality index and pollutant concentrations. + """ + try: + api_key = await _resolve_api_key() + if not api_key: + return AirPollutionResponse( + success=False, + error="OpenWeatherMap API not configured. Set OPENWEATHER_API_KEY environment variable.", + ) + + logger.info("OpenWeather air pollution city=%r", city) + + async with httpx.AsyncClient(timeout=15) as client: + geo = await _geocode_city(client, city, api_key) + if geo is None: + return AirPollutionResponse( + success=False, + error=f"City '{city}' not found.", + ) + + params = { + "lat": geo["lat"], + "lon": geo["lon"], + "appid": api_key, + } + response = await client.get(f"{_OWM_BASE}/air_pollution", params=params) + response.raise_for_status() + data = response.json() + + pollution_list = data.get("list", []) + if not pollution_list: + return AirPollutionResponse( + success=False, + error=f"No air pollution data available for '{city}'.", + ) + + entry = pollution_list[0] + main_data = entry.get("main", {}) + components = entry.get("components", {}) + + return AirPollutionResponse( + success=True, + data=AirPollutionData( + city=geo["name"], + country=geo["country"], + aqi=main_data.get("aqi", 0), + components=AirPollutionComponent( + co=components.get("co"), + no=components.get("no"), + no2=components.get("no2"), + o3=components.get("o3"), + so2=components.get("so2"), + pm2_5=components.get("pm2_5"), + pm10=components.get("pm10"), + nh3=components.get("nh3"), + ), + dt=entry.get("dt"), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("OpenWeather air pollution HTTP error") + return AirPollutionResponse( + success=False, + error=f"OpenWeatherMap API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("OpenWeather air pollution failed") + return AirPollutionResponse( + success=False, error=f"OpenWeather air pollution failed: {e}" + ) + + +@tool() +async def openweather_geocode( + query: str, + limit: int = 5, +) -> GeocodingResponse: + """Geocode a location name to latitude/longitude coordinates. + + Uses the OpenWeatherMap Geocoding API to convert city names, zip codes, + or location strings into geographic coordinates. Useful for finding + exact coordinates before using other weather tools. + + Args: + query: Location to search for (e.g. "London", "New York,US", + "Tokyo,JP", "10001"). + limit: Maximum number of results to return (1-5, default 5). + + Returns: + List of matching locations with name, country, state, and coordinates. + """ + try: + api_key = await _resolve_api_key() + if not api_key: + return GeocodingResponse( + success=False, + error="OpenWeatherMap API not configured. Set OPENWEATHER_API_KEY environment variable.", + ) + limit = max(1, min(limit, 5)) + + logger.info("OpenWeather geocode query=%r limit=%d", query, limit) + + async with httpx.AsyncClient(timeout=15) as client: + params = {"q": query, "limit": limit, "appid": api_key} + response = await client.get(f"{_GEO_BASE}/direct", params=params) # type: ignore[arg-type] + response.raise_for_status() + results = response.json() + + locations = [ + GeocodingResult( + name=r.get("name", ""), + country=r.get("country", ""), + state=r.get("state"), + lat=r["lat"], + lon=r["lon"], + ) + for r in results + ] + + logger.info("OpenWeather geocode complete count=%d", len(locations)) + + return GeocodingResponse( + success=True, + data=GeocodingData( + query=query, + results=locations, + count=len(locations), + ), + ) + except httpx.HTTPStatusError as e: + logger.exception("OpenWeather geocode HTTP error") + return GeocodingResponse( + success=False, + error=f"OpenWeatherMap API error ({e.response.status_code}): {e.response.text}", + ) + except Exception as e: + logger.exception("OpenWeather geocode failed") + return GeocodingResponse( + success=False, error=f"OpenWeather geocode failed: {e}" + ) diff --git a/src/tools/weather/schemas.py b/src/tools/weather/schemas.py new file mode 100644 index 0000000..48032da --- /dev/null +++ b/src/tools/weather/schemas.py @@ -0,0 +1,174 @@ +"""Pydantic output schemas for weather tools.""" + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# OpenWeatherMap Schemas +# ============================================================================= + + +class WeatherCondition(BaseModel): + """Weather condition details from OpenWeatherMap.""" + + main: str = Field( + ..., description="Weather group (Rain, Snow, Clouds, Clear, etc.)" + ) + description: str = Field(..., description="Detailed weather condition description") + icon: str = Field(..., description="Weather icon ID (e.g. '01d', '10n')") + + +class CurrentWeatherData(BaseModel): + """Output data for openweather_get_current tool.""" + + city: str = Field(..., description="City name") + country: str = Field("", description="ISO 3166-1 alpha-2 country code") + temperature: float = Field(..., description="Current temperature") + feels_like: float = Field( + ..., description="Perceived temperature accounting for wind/humidity" + ) + temp_min: float = Field(..., description="Minimum temperature at the moment") + temp_max: float = Field(..., description="Maximum temperature at the moment") + humidity: int = Field(..., description="Humidity percentage (0-100)") + pressure: int = Field(..., description="Atmospheric pressure in hPa (hectopascals)") + wind_speed: float = Field( + ..., description="Wind speed (m/s for metric, mph for imperial)" + ) + wind_deg: int | None = Field( + None, + description="Wind direction in meteorological degrees (0=N, 90=E, 180=S, 270=W)", + ) + wind_gust: float | None = Field(None, description="Wind gust speed") + visibility: int | None = Field(None, description="Visibility in meters (max 10km)") + clouds: int | None = Field(None, description="Cloudiness percentage (0-100)") + conditions: list[WeatherCondition] = Field( + default_factory=list, description="Weather conditions" + ) + units: str = Field( + "metric", description="Units of measurement used (metric, imperial, standard)" + ) + sunrise: int | None = Field(None, description="Sunrise time as Unix UTC timestamp") + sunset: int | None = Field(None, description="Sunset time as Unix UTC timestamp") + + +class ForecastEntry(BaseModel): + """A single forecast time slot (3-hour interval).""" + + dt: int = Field(..., description="Unix UTC timestamp for this forecast point") + dt_txt: str = Field( + ..., description="Human-readable date/time string (YYYY-MM-DD HH:MM:SS)" + ) + temperature: float = Field(..., description="Forecasted temperature") + feels_like: float = Field(..., description="Perceived temperature") + temp_min: float = Field(..., description="Minimum temperature in this interval") + temp_max: float = Field(..., description="Maximum temperature in this interval") + humidity: int = Field(..., description="Humidity percentage (0-100)") + pressure: int = Field(..., description="Atmospheric pressure in hPa") + wind_speed: float = Field(..., description="Wind speed") + wind_deg: int | None = Field(None, description="Wind direction in degrees") + pop: float | None = Field( + None, description="Probability of precipitation (0.0-1.0)" + ) + conditions: list[WeatherCondition] = Field( + default_factory=list, description="Weather conditions" + ) + + +class ForecastWeatherData(BaseModel): + """Output data for openweather_get_forecast tool.""" + + city: str = Field(..., description="City name") + country: str = Field("", description="Country code") + days: int = Field(..., description="Number of days requested") + units: str = Field("metric", description="Units of measurement used") + forecasts: list[ForecastEntry] = Field( + default_factory=list, description="List of forecast entries (3-hour intervals)" + ) + + +class AirPollutionComponent(BaseModel): + """Individual air quality component measurement.""" + + co: float | None = Field(None, description="Carbon monoxide concentration in ug/m3") + no: float | None = Field( + None, description="Nitrogen monoxide concentration in ug/m3" + ) + no2: float | None = Field( + None, description="Nitrogen dioxide concentration in ug/m3" + ) + o3: float | None = Field(None, description="Ozone concentration in ug/m3") + so2: float | None = Field( + None, description="Sulphur dioxide concentration in ug/m3" + ) + pm2_5: float | None = Field( + None, description="Fine particulate matter (PM2.5) in ug/m3" + ) + pm10: float | None = Field( + None, description="Coarse particulate matter (PM10) in ug/m3" + ) + nh3: float | None = Field(None, description="Ammonia concentration in ug/m3") + + +class AirPollutionData(BaseModel): + """Output data for openweather_get_air_pollution tool.""" + + city: str = Field(..., description="City name") + country: str = Field("", description="Country code") + aqi: int = Field( + ..., + description="Air Quality Index (1=Good, 2=Fair, 3=Moderate, 4=Poor, 5=Very Poor)", + ) + components: AirPollutionComponent = Field( + ..., description="Pollutant concentrations" + ) + dt: int | None = Field(None, description="Unix UTC timestamp of the measurement") + + +class GeocodingResult(BaseModel): + """A single geocoding result.""" + + name: str = Field(..., description="Location name") + country: str = Field("", description="Country code") + state: str | None = Field(None, description="State or region name") + lat: float = Field(..., description="Latitude") + lon: float = Field(..., description="Longitude") + + +class GeocodingData(BaseModel): + """Output data for openweather_geocode tool.""" + + query: str = Field(..., description="Original search query") + results: list[GeocodingResult] = Field( + default_factory=list, description="List of matching locations" + ) + count: int = Field(..., description="Number of results returned") + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class CurrentWeatherResponse(ToolResponse[CurrentWeatherData]): + """Response schema for openweather_get_current tool.""" + + pass + + +class ForecastWeatherResponse(ToolResponse[ForecastWeatherData]): + """Response schema for openweather_get_forecast tool.""" + + pass + + +class AirPollutionResponse(ToolResponse[AirPollutionData]): + """Response schema for openweather_get_air_pollution tool.""" + + pass + + +class GeocodingResponse(ToolResponse[GeocodingData]): + """Response schema for openweather_geocode tool.""" + + pass diff --git a/src/tools/web_scraping/SKILL.md b/src/tools/web_scraping/SKILL.md new file mode 100644 index 0000000..959225c --- /dev/null +++ b/src/tools/web_scraping/SKILL.md @@ -0,0 +1,178 @@ +--- +name: web-scraping-crawling +description: Scrape, crawl, and extract content from web pages. Use when the user needs to extract text, articles, or structured data from websites, or crawl multiple pages from a site. +--- + +# Web Scraping & Crawling Tools + +Tools for scraping individual pages, crawling entire websites, and extracting structured data. + +## Free Tools (No API Key) + +### Crawl4AI - Browser-based scraping + +```python +result = await crawl4ai_scrape( + url="https://example.com", + extract_markdown=True, + max_length=5000 +) +``` + +### newspaper4k - Article extraction + +```python +result = await newspaper_extract( + url="https://example.com/article", + max_length=10000 +) +``` + +### trafilatura - Content extraction + +```python +result = await trafilatura_extract( + url="https://example.com", + include_comments=False, + output_format="txt" # or "markdown", "json", "xml" +) +``` + +## API-Powered Tools + +### Firecrawl (FIRECRAWL_API_KEY) + +```python +# Single page +result = await firecrawl_scrape(url="https://example.com") + +# Multi-page crawl +result = await firecrawl_crawl(url="https://example.com", limit=10) +``` + +### Spider (SPIDER_API_KEY) + +```python +# Single page +result = await spider_scrape(url="https://example.com") + +# Multi-page crawl +result = await spider_crawl(url="https://example.com", limit=10) +``` + +### Jina Reader (JINA_API_KEY optional) + +```python +# Read a URL +result = await jina_reader(url="https://example.com") + +# Web search +result = await jina_search(query="latest AI news") +``` + +### ScrapeGraph (SGAI_API_KEY) + +```python +# AI-powered structured extraction +result = await scrapegraph_scrape( + url="https://example.com", + prompt="Extract all product names and prices" +) + +# Convert page to markdown +result = await scrapegraph_markdownify(url="https://example.com") +``` + +### AgentQL (AGENTQL_API_KEY) + +```python +result = await agentql_query( + url="https://example.com", + query='{ text_content[] }' +) +``` + +### Browserbase (BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID) + +```python +result = await browserbase_scrape(url="https://example.com") +``` + +### BrightData (BRIGHTDATA_API_KEY) + +```python +result = await brightdata_scrape(url="https://example.com") +``` + +### Oxylabs (OXYLABS_USERNAME, OXYLABS_PASSWORD) + +```python +result = await oxylabs_scrape( + url="https://example.com", + source="universal", + render_javascript=False +) +``` + +### Linkup (LINKUP_API_KEY) + +```python +result = await linkup_search( + query="machine learning tutorials", + depth="standard" # or "deep" +) +``` + +### Apify (APIFY_API_TOKEN) + +```python +result = await apify_run_actor( + actor_id="apify/web-scraper", + input_data={"startUrls": [{"url": "https://example.com"}]} +) +``` + +## Response Format + +All tools return a consistent response: + +```json +{ + "success": true, + "data": { + "url": "https://example.com", + "title": "Page Title", + "content": "Extracted text content...", + "markdown": "# Markdown content...", + "metadata": {"authors": ["..."], "publish_date": "..."} + } +} +``` + +Crawl tools return multiple pages: + +```json +{ + "success": true, + "data": { + "pages": [ + {"url": "...", "title": "...", "content": "..."} + ], + "total_pages": 5 + } +} +``` + +## When to Use + +| Need | Tool | +|------|------| +| Simple article text | `newspaper_extract` | +| Clean main content | `trafilatura_extract` | +| JavaScript-heavy pages | `crawl4ai_scrape`, `browserbase_scrape` | +| Multi-page crawl | `firecrawl_crawl`, `spider_crawl` | +| Structured data extraction | `scrapegraph_scrape`, `agentql_query` | +| Bypass anti-bot protection | `brightdata_scrape`, `oxylabs_scrape` | +| Web search | `jina_search`, `linkup_search` | +| Pre-built scraper workflows | `apify_run_actor` | +| Page to markdown | `firecrawl_scrape`, `scrapegraph_markdownify`, `jina_reader` | diff --git a/src/tools/web_scraping/__init__.py b/src/tools/web_scraping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/web_scraping/agentql.py b/src/tools/web_scraping/agentql.py new file mode 100644 index 0000000..2a343e5 --- /dev/null +++ b/src/tools/web_scraping/agentql.py @@ -0,0 +1,105 @@ +"""Web scraping tool using AgentQL for structured data extraction.""" + +from __future__ import annotations + +import json +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.agentql") + +_DEFAULT_QUERY = """ +{ + text_content[] +} +""" + + +@tool() +async def agentql_query( + url: str, + query: str | None = None, +) -> ScrapeResponse: + """Scrape a web page using AgentQL to extract structured data. + + Uses AgentQL's query language with a headless browser to extract + specific data from web pages. Requires AGENTQL_API_KEY. + + Args: + url: The URL of the website to scrape. + query: AgentQL query string for extraction. If not provided, extracts all text content. + + Returns: + Scraped page data with extracted content. + """ + try: + try: + import agentql + from playwright.sync_api import sync_playwright + except ImportError as err: + raise ImportError( + "agentql is required for AgentQL tools. " + "Install with: pip install agentql" + ) from err + + api_key = await resolve_credential("AGENTQL_API_KEY") + if not api_key: + return ScrapeResponse( + success=False, + error="AgentQL API not configured. Set AGENTQL_API_KEY.", + ) + + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("AgentQL query start url=%s", url) + + agentql_query_str = query if query else _DEFAULT_QUERY + + with ( + sync_playwright() as playwright, + playwright.chromium.launch(headless=True) as browser, + ): + page = agentql.wrap(browser.new_page()) + page.goto(url) + + response = page.query_data(agentql_query_str) + + if not response: + return ScrapeResponse( + success=False, error="No data returned from AgentQL query" + ) + + if isinstance(response, dict) and "text_content" in response: + text_items = [ + item + for item in response["text_content"] + if item and str(item).strip() + ] + content = " ".join(list(set(text_items))) + elif isinstance(response, dict): + content = json.dumps(response, indent=2) + else: + content = str(response) + + data = ScrapedPageData( + url=url, + title=None, + content=content, + markdown=None, + metadata={"query": agentql_query_str.strip()}, + ) + + logger.info( + "AgentQL query complete url=%s content_length=%d", url, len(content) + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("AgentQL query failed") + return ScrapeResponse(success=False, error=f"AgentQL query failed: {str(e)}") diff --git a/src/tools/web_scraping/apify.py b/src/tools/web_scraping/apify.py new file mode 100644 index 0000000..ef71069 --- /dev/null +++ b/src/tools/web_scraping/apify.py @@ -0,0 +1,92 @@ +"""Web scraping tool using Apify cloud actors.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ActorRunData, ActorRunResponse + +logger = logging.getLogger("humcp.tools.apify") + + +@tool() +async def apify_run_actor( + actor_id: str, + input_data: dict[str, Any] | None = None, +) -> ActorRunResponse: + """Run an Apify actor and return its results. + + Apify actors are pre-built scrapers and automation tools that run in the cloud. + Pass an actor ID (e.g., 'apify/web-scraper') and optional input data. + Requires APIFY_API_TOKEN. + + Args: + actor_id: The Apify actor ID to run (e.g., 'apify/web-scraper'). + input_data: Input parameters for the actor as a dictionary. + + Returns: + Results from the actor run. + """ + try: + try: + from apify_client import ApifyClient + except ImportError as err: + raise ImportError( + "apify-client is required for Apify tools. " + "Install with: pip install apify-client" + ) from err + + api_token = await resolve_credential("APIFY_API_TOKEN") + if not api_token: + return ActorRunResponse( + success=False, + error="Apify API not configured. Set APIFY_API_TOKEN.", + ) + + if not actor_id: + return ActorRunResponse(success=False, error="Actor ID is required") + + logger.info("Apify run actor start actor_id=%s", actor_id) + + client = ApifyClient(api_token) + run_input = input_data if input_data else {} + + details = client.actor(actor_id=actor_id).call(run_input=run_input) + + if not details: + return ActorRunResponse( + success=False, + error=f"Actor {actor_id} did not return run details", + ) + + run_id = details.get("id") + if not run_id: + return ActorRunResponse( + success=False, + error=f"No run ID returned for actor {actor_id}", + ) + + run = client.run(run_id=run_id) + results = run.dataset().list_items(clean=True).items + + data = ActorRunData( + actor_id=actor_id, + results=results, + total_items=len(results), + ) + + logger.info( + "Apify run actor complete actor_id=%s items=%d", actor_id, len(results) + ) + return ActorRunResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Apify run actor failed") + return ActorRunResponse( + success=False, error=f"Apify run actor failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/brightdata.py b/src/tools/web_scraping/brightdata.py new file mode 100644 index 0000000..2f6d0ad --- /dev/null +++ b/src/tools/web_scraping/brightdata.py @@ -0,0 +1,94 @@ +"""Web scraping tool using BrightData Web Unlocker API.""" + +from __future__ import annotations + +import json +import logging +import os + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.brightdata") + +_BRIGHTDATA_ENDPOINT = "https://api.brightdata.com/request" + + +@tool() +async def brightdata_scrape( + url: str, + country: str | None = None, +) -> ScrapeResponse: + """Scrape a web page as markdown using BrightData Web Unlocker. + + Uses BrightData's proxy and unlocker infrastructure to bypass restrictions + and return page content as clean markdown. Requires BRIGHTDATA_API_KEY. + + Args: + url: The URL to scrape. + country: Two-letter country code for geolocation (e.g., 'us', 'gb', 'de'). Routes request through the specified country. + + Returns: + Scraped page data with markdown content. + """ + try: + api_key = await resolve_credential("BRIGHTDATA_API_KEY") + if not api_key: + return ScrapeResponse( + success=False, + error="BrightData API not configured. Set BRIGHTDATA_API_KEY.", + ) + + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("BrightData scrape start url=%s country=%s", url, country) + + zone = os.getenv("BRIGHTDATA_WEB_UNLOCKER_ZONE", "unblocker") + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + payload: dict = { + "url": url, + "zone": zone, + "format": "raw", + "data_format": "markdown", + } + if country: + payload["country"] = country.lower() + + async with httpx.AsyncClient() as client: + response = await client.post( + _BRIGHTDATA_ENDPOINT, + headers=headers, + content=json.dumps(payload), + timeout=120.0, + ) + response.raise_for_status() + + content = response.text + + data = ScrapedPageData( + url=url, + title=None, + content=content, + markdown=content, + ) + + logger.info( + "BrightData scrape complete url=%s content_length=%d", + url, + len(content), + ) + return ScrapeResponse(success=True, data=data) + + except Exception as e: + logger.exception("BrightData scrape failed") + return ScrapeResponse( + success=False, error=f"BrightData scrape failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/browserbase.py b/src/tools/web_scraping/browserbase.py new file mode 100644 index 0000000..2e9b25b --- /dev/null +++ b/src/tools/web_scraping/browserbase.py @@ -0,0 +1,129 @@ +"""Web scraping tool using Browserbase cloud browser infrastructure.""" + +from __future__ import annotations + +import logging +import re + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.browserbase") + + +def _extract_text_from_html(html: str) -> str: + """Extract visible text content from HTML.""" + cleaned = re.sub( + r"]*>.*?", "", html, flags=re.DOTALL | re.IGNORECASE + ) + cleaned = re.sub( + r"]*>.*?", "", cleaned, flags=re.DOTALL | re.IGNORECASE + ) + cleaned = re.sub(r"", "", cleaned, flags=re.DOTALL) + cleaned = re.sub(r"<[^>]+>", " ", cleaned) + cleaned = cleaned.replace(" ", " ") + cleaned = cleaned.replace("&", "&") + cleaned = cleaned.replace("<", "<") + cleaned = cleaned.replace(">", ">") + cleaned = cleaned.replace(""", '"') + cleaned = cleaned.replace("'", "'") + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned.strip() + + +@tool() +async def browserbase_scrape( + url: str, + max_content_length: int = 100000, +) -> ScrapeResponse: + """Scrape a web page using Browserbase cloud browser. + + Uses Browserbase to spin up a cloud-hosted browser, navigate to the URL, + and extract the page content. Good for JavaScript-heavy pages. + Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID. + + Args: + url: The URL to scrape. + max_content_length: Maximum content length in characters. Defaults to 100000. + + Returns: + Scraped page data with extracted text content. + """ + try: + try: + from browserbase import Browserbase + except ImportError as err: + raise ImportError( + "browserbase is required for Browserbase tools. " + "Install with: pip install browserbase" + ) from err + + api_key = await resolve_credential("BROWSERBASE_API_KEY") + project_id = await resolve_credential("BROWSERBASE_PROJECT_ID") + + if not api_key: + return ScrapeResponse( + success=False, + error="Browserbase API not configured. Set BROWSERBASE_API_KEY.", + ) + if not project_id: + return ScrapeResponse( + success=False, + error="Browserbase project not configured. Set BROWSERBASE_PROJECT_ID.", + ) + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("Browserbase scrape start url=%s", url) + + try: + from playwright.sync_api import sync_playwright + except ImportError as err: + raise ImportError( + "playwright is required. Install with: pip install playwright && playwright install" + ) from err + + app = Browserbase(api_key=api_key) + session = app.sessions.create(project_id=project_id) + connect_url = session.connect_url if session else "" + + with sync_playwright() as pw: + browser = pw.chromium.connect_over_cdp(connect_url) + context = browser.contexts[0] + page = context.pages[0] if context.pages else context.new_page() + + page.goto(url, wait_until="networkidle") + title = page.title() + raw_content = page.content() + browser.close() + + content = _extract_text_from_html(raw_content) + + if max_content_length and len(content) > max_content_length: + content = ( + content[:max_content_length] + + f"\n\n[Content truncated. Original length: {len(content)} characters.]" + ) + + data = ScrapedPageData( + url=url, + title=title if title else None, + content=content, + markdown=None, + ) + + logger.info( + "Browserbase scrape complete url=%s content_length=%d", + url, + len(content), + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Browserbase scrape failed") + return ScrapeResponse( + success=False, error=f"Browserbase scrape failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/crawl4ai.py b/src/tools/web_scraping/crawl4ai.py new file mode 100644 index 0000000..6542959 --- /dev/null +++ b/src/tools/web_scraping/crawl4ai.py @@ -0,0 +1,267 @@ +"""Web scraping tool using Crawl4AI (free, no API key required).""" + +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + Crawl4aiLinksData, + Crawl4aiLinksResponse, + LinkItem, + ScrapedPageData, + ScrapeResponse, +) + +logger = logging.getLogger("humcp.tools.crawl4ai") + + +@tool() +async def crawl4ai_scrape( + url: str, + extract_markdown: bool = True, + max_length: int = 5000, +) -> ScrapeResponse: + """Scrape a web page and extract its content using Crawl4AI. + + Uses a headless browser to render the page, then extracts clean text + or markdown. Free to use with no API key required. + + Args: + url: The URL of the page to scrape. + extract_markdown: Whether to return content as markdown. Defaults to True. + max_length: Maximum character length of returned content. Defaults to 5000. + + Returns: + Scraped page data including content and optional markdown. + """ + if not url: + return ScrapeResponse(success=False, error="URL is required") + + try: + try: + from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig + except ImportError as err: + raise ImportError( + "crawl4ai is required for Crawl4AI tools. " + "Install with: pip install crawl4ai" + ) from err + + logger.info("Crawl4AI scrape start url=%s", url) + + browser_config = BrowserConfig(headless=True, verbose=False) + + async with AsyncWebCrawler(config=browser_config) as crawler: + config = CrawlerRunConfig( + page_timeout=60000, + wait_until="domcontentloaded", + cache_mode="bypass", + verbose=False, + ) + result = await crawler.arun(url=url, config=config) + + if not result: + return ScrapeResponse( + success=False, error="No content returned from page" + ) + + # Extract markdown content + markdown_content = None + text_content = "" + + if hasattr(result, "fit_markdown") and result.fit_markdown: + markdown_content = result.fit_markdown + elif hasattr(result, "markdown") and result.markdown: + if hasattr(result.markdown, "raw_markdown"): + markdown_content = result.markdown.raw_markdown + else: + markdown_content = str(result.markdown) + + if hasattr(result, "text") and result.text: + text_content = result.text + elif markdown_content: + text_content = markdown_content + + if not text_content and not markdown_content: + return ScrapeResponse( + success=False, + error="Could not extract readable content from page", + ) + + # Truncate if needed + if max_length and len(text_content) > max_length: + text_content = text_content[:max_length] + "..." + if markdown_content and max_length and len(markdown_content) > max_length: + markdown_content = markdown_content[:max_length] + "..." + + title = None + if hasattr(result, "title"): + title = result.title + + data = ScrapedPageData( + url=url, + title=title, + content=text_content, + markdown=markdown_content if extract_markdown else None, + ) + + logger.info( + "Crawl4AI scrape complete url=%s content_length=%d", + url, + len(text_content), + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Crawl4AI scrape failed") + return ScrapeResponse(success=False, error=f"Crawl4AI scrape failed: {str(e)}") + + +def _parse_domain(url: str) -> str: + """Extract the domain from a URL for internal/external classification.""" + try: + from urllib.parse import urlparse + + parsed = urlparse(url) + netloc = parsed.netloc.lower() + return netloc.removeprefix("www.") + except Exception: + return "" + + +@tool() +async def crawl4ai_extract_links( + url: str, + include_external: bool = True, + max_links: int = 200, +) -> Crawl4aiLinksResponse: + """Extract all links from a web page using Crawl4AI. + + Uses a headless browser to render the page, then extracts all anchor + links with their text and classifies them as internal or external. + Free to use with no API key required. + + Args: + url: The URL of the page to extract links from. + include_external: Whether to include external links. Defaults to True. + max_links: Maximum number of links to return. Defaults to 200. + + Returns: + Extracted links with metadata including internal/external classification. + """ + if not url: + return Crawl4aiLinksResponse(success=False, error="URL is required") + + try: + try: + from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig + except ImportError as err: + raise ImportError( + "crawl4ai is required for Crawl4AI tools. " + "Install with: pip install crawl4ai" + ) from err + + logger.info("Crawl4AI extract_links start url=%s", url) + + source_domain = _parse_domain(url) + browser_config = BrowserConfig(headless=True, verbose=False) + + async with AsyncWebCrawler(config=browser_config) as crawler: + config = CrawlerRunConfig( + page_timeout=60000, + wait_until="domcontentloaded", + cache_mode="bypass", + verbose=False, + ) + result = await crawler.arun(url=url, config=config) + + if not result: + return Crawl4aiLinksResponse( + success=False, error="No content returned from page" + ) + + raw_links: list[dict] = [] + if hasattr(result, "links") and result.links: + links_data = result.links + if isinstance(links_data, dict): + for link_list in links_data.values(): + if isinstance(link_list, list): + raw_links.extend(link_list) + elif isinstance(links_data, list): + raw_links = links_data + + link_items: list[LinkItem] = [] + internal_count = 0 + external_count = 0 + + seen_hrefs: set[str] = set() + + for raw_link in raw_links: + if len(link_items) >= max_links: + break + + href = "" + text = None + + if isinstance(raw_link, dict): + href = raw_link.get("href", raw_link.get("url", "")) + text = raw_link.get("text", raw_link.get("anchor", None)) + elif isinstance(raw_link, str): + href = raw_link + else: + continue + + if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")): + continue + + if href in seen_hrefs: + continue + seen_hrefs.add(href) + + link_domain = _parse_domain(href) + is_external = bool( + link_domain and source_domain and link_domain != source_domain + ) + + if not include_external and is_external: + continue + + if is_external: + external_count += 1 + else: + internal_count += 1 + + link_items.append( + LinkItem( + href=href, + text=text if text else None, + is_external=is_external, + ) + ) + + data = Crawl4aiLinksData( + url=url, + links=link_items, + total_links=len(link_items), + internal_count=internal_count, + external_count=external_count, + ) + + logger.info( + "Crawl4AI extract_links complete url=%s total=%d internal=%d external=%d", + url, + len(link_items), + internal_count, + external_count, + ) + return Crawl4aiLinksResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Crawl4AI extract_links failed") + return Crawl4aiLinksResponse( + success=False, error=f"Crawl4AI extract_links failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/firecrawl.py b/src/tools/web_scraping/firecrawl.py new file mode 100644 index 0000000..0a53dc8 --- /dev/null +++ b/src/tools/web_scraping/firecrawl.py @@ -0,0 +1,328 @@ +"""Web scraping and crawling tools using Firecrawl API.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + CrawlResponse, + CrawlResultData, + FirecrawlSearchData, + FirecrawlSearchResponse, + MapResponse, + MapResultData, + ScrapedPageData, + ScrapeResponse, +) + +if TYPE_CHECKING: + from firecrawl import FirecrawlApp + +logger = logging.getLogger("humcp.tools.firecrawl") + + +def _get_firecrawl_app(api_key: str) -> FirecrawlApp: + """Create a FirecrawlApp instance with lazy import.""" + try: + from firecrawl import FirecrawlApp + except ImportError as err: + raise ImportError( + "firecrawl-py is required for Firecrawl tools. " + "Install with: pip install firecrawl-py" + ) from err + + return FirecrawlApp(api_key=api_key) + + +@tool() +async def firecrawl_scrape( + url: str, + formats: list[str] | None = None, +) -> ScrapeResponse: + """Scrape a single web page using Firecrawl. + + Extracts clean content from a URL with optional format selection. + + Args: + url: The URL to scrape. + formats: Output formats (e.g., ['markdown', 'html']). Defaults to markdown. + + Returns: + Scraped page data with content and metadata. + """ + try: + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("Firecrawl scrape start url=%s", url) + + api_key = await resolve_credential("FIRECRAWL_API_KEY") + if not api_key: + return ScrapeResponse( + success=False, + error="Firecrawl API not configured. Set FIRECRAWL_API_KEY.", + ) + + app = _get_firecrawl_app(api_key) + params = {} + if formats: + params["formats"] = formats + + result = app.scrape(url, **params) + result_dict = result.model_dump() if hasattr(result, "model_dump") else result + + content = result_dict.get("markdown", result_dict.get("html", "")) + markdown = result_dict.get("markdown") + title = result_dict.get("metadata", {}).get("title") + metadata = result_dict.get("metadata") + + data = ScrapedPageData( + url=url, + title=title, + content=content, + markdown=markdown, + metadata=metadata, + ) + + logger.info("Firecrawl scrape complete url=%s", url) + return ScrapeResponse(success=True, data=data) + + except ValueError as e: + return ScrapeResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Firecrawl scrape failed") + return ScrapeResponse(success=False, error=f"Firecrawl scrape failed: {str(e)}") + + +@tool() +async def firecrawl_crawl( + url: str, + limit: int = 10, + formats: list[str] | None = None, +) -> CrawlResponse: + """Crawl a website starting from a URL using Firecrawl. + + Discovers and scrapes multiple pages from a website up to the specified limit. + + Args: + url: The starting URL to crawl from. + limit: Maximum number of pages to crawl. Defaults to 10. + formats: Output formats for each page (e.g., ['markdown']). Defaults to markdown. + + Returns: + Crawl results with data from all discovered pages. + """ + try: + if not url: + return CrawlResponse(success=False, error="URL is required") + + logger.info("Firecrawl crawl start url=%s limit=%d", url, limit) + + api_key = await resolve_credential("FIRECRAWL_API_KEY") + if not api_key: + return CrawlResponse( + success=False, + error="Firecrawl API not configured. Set FIRECRAWL_API_KEY.", + ) + + app = _get_firecrawl_app(api_key) + params: dict = {"limit": limit, "poll_interval": 30} + if formats: + try: + from firecrawl.types import ScrapeOptions + except ImportError: + pass + else: + params["scrape_options"] = ScrapeOptions(formats=formats) + + result = app.crawl(url, **params) + result_dict = result.model_dump() if hasattr(result, "model_dump") else result + + pages = [] + crawl_data = ( + result_dict.get("data", []) if isinstance(result_dict, dict) else [] + ) + for page in crawl_data: + content = page.get("markdown", page.get("html", "")) + pages.append( + ScrapedPageData( + url=page.get("url", url), + title=page.get("metadata", {}).get("title"), + content=content, + markdown=page.get("markdown"), + metadata=page.get("metadata"), + ) + ) + + data = CrawlResultData(pages=pages, total_pages=len(pages)) + + logger.info("Firecrawl crawl complete url=%s pages=%d", url, len(pages)) + return CrawlResponse(success=True, data=data) + + except ValueError as e: + return CrawlResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Firecrawl crawl failed") + return CrawlResponse(success=False, error=f"Firecrawl crawl failed: {str(e)}") + + +@tool() +async def firecrawl_map( + url: str, + limit: int = 100, + search: str | None = None, +) -> MapResponse: + """Discover URLs on a website using Firecrawl's map endpoint. + + Quickly maps all accessible URLs on a website without scraping content. + Useful for site discovery and planning targeted scrapes. + + Args: + url: The base URL to map. + limit: Maximum number of URLs to discover. Defaults to 100. + search: Optional search term to filter discovered URLs. + + Returns: + List of discovered URLs on the website. + """ + try: + if not url: + return MapResponse(success=False, error="URL is required") + + logger.info("Firecrawl map start url=%s limit=%d", url, limit) + + api_key = await resolve_credential("FIRECRAWL_API_KEY") + if not api_key: + return MapResponse( + success=False, + error="Firecrawl API not configured. Set FIRECRAWL_API_KEY.", + ) + + app = _get_firecrawl_app(api_key) + params: dict = {"limit": limit} + if search: + params["search"] = search + + result = app.map(url, **params) + + urls: list[str] = [] + if isinstance(result, list): + urls = [str(u) for u in result] + elif isinstance(result, dict): + raw: list[Any] = ( + result.get("urls", result.get("links", result.get("data", []))) or [] + ) + if isinstance(raw, list): + urls = [str(u) for u in raw] + elif hasattr(result, "model_dump"): + result_dict = result.model_dump() + raw = result_dict.get("urls", result_dict.get("links", [])) or [] + if isinstance(raw, list): + urls = [str(u) for u in raw] + + data = MapResultData( + base_url=url, + urls=urls, + total_urls=len(urls), + ) + + logger.info("Firecrawl map complete url=%s urls=%d", url, len(urls)) + return MapResponse(success=True, data=data) + + except ValueError as e: + return MapResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Firecrawl map failed") + return MapResponse(success=False, error=f"Firecrawl map failed: {str(e)}") + + +@tool() +async def firecrawl_search( + query: str, + limit: int = 5, + formats: list[str] | None = None, +) -> FirecrawlSearchResponse: + """Search the web and retrieve full page content using Firecrawl. + + Performs a web search and returns scraped content for each result, + combining search with scraping in a single operation. + + Args: + query: The search query to execute. + limit: Maximum number of results to return. Defaults to 5. + formats: Output formats for each result (e.g., ['markdown']). Defaults to markdown. + + Returns: + Search results with full scraped page content. + """ + try: + if not query: + return FirecrawlSearchResponse(success=False, error="Query is required") + + logger.info("Firecrawl search start query=%s limit=%d", query, limit) + + api_key = await resolve_credential("FIRECRAWL_API_KEY") + if not api_key: + return FirecrawlSearchResponse( + success=False, + error="Firecrawl API not configured. Set FIRECRAWL_API_KEY.", + ) + + app = _get_firecrawl_app(api_key) + params: dict = {"limit": limit} + if formats: + params["scrape_options"] = {"formats": formats} + + result = app.search(query, **params) + + results: list[ScrapedPageData] = [] + raw_data: list = [] + + if isinstance(result, list): + raw_data = result + elif isinstance(result, dict): + raw_data = result.get("data", result.get("results", [])) or [] + elif hasattr(result, "model_dump"): + result_dict = result.model_dump() + raw_data = result_dict.get("data", result_dict.get("results", [])) + elif hasattr(result, "data"): + raw_data = result.data if isinstance(result.data, list) else [] + + for item in raw_data: + item_dict = item.model_dump() if hasattr(item, "model_dump") else item + if not isinstance(item_dict, dict): + continue + content: str = item_dict.get("markdown", item_dict.get("content", "")) or "" + results.append( + ScrapedPageData( + url=item_dict.get("url", "") or "", + title=item_dict.get("metadata", {}).get( + "title", item_dict.get("title") + ), + content=content, + markdown=item_dict.get("markdown"), + metadata=item_dict.get("metadata"), + ) + ) + + data = FirecrawlSearchData( + query=query, + results=results, + total_results=len(results), + ) + + logger.info( + "Firecrawl search complete query=%s results=%d", query, len(results) + ) + return FirecrawlSearchResponse(success=True, data=data) + + except ValueError as e: + return FirecrawlSearchResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Firecrawl search failed") + return FirecrawlSearchResponse( + success=False, error=f"Firecrawl search failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/jina.py b/src/tools/web_scraping/jina.py new file mode 100644 index 0000000..3e7dbb5 --- /dev/null +++ b/src/tools/web_scraping/jina.py @@ -0,0 +1,190 @@ +"""Web reading and search tools using Jina Reader API.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + ScrapedPageData, + ScrapeResponse, + SearchResponse, + SearchResultData, + SearchResultItem, +) + +logger = logging.getLogger("humcp.tools.jina") + +_JINA_READER_BASE_URL = "https://r.jina.ai/" +_JINA_SEARCH_URL = "https://s.jina.ai/" +_MAX_CONTENT_LENGTH = 10000 + + +def _get_jina_headers( + api_key: str | None = None, + target_selector: str | None = None, + return_format: str | None = None, + include_links: bool = False, +) -> dict[str, str]: + """Build request headers for Jina API calls.""" + headers = { + "Accept": "application/json", + "X-With-Links-Summary": "true", + "X-With-Images-Summary": "true", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + if target_selector: + headers["X-Target-Selector"] = target_selector + if return_format: + headers["X-Return-Format"] = return_format + if include_links: + headers["X-Return-Links"] = "true" + return headers + + +@tool() +async def jina_reader( + url: str, + max_content_length: int = _MAX_CONTENT_LENGTH, + target_selector: str | None = None, + return_format: str | None = None, + include_links: bool = False, +) -> ScrapeResponse: + """Read and extract content from a URL using Jina Reader API. + + Converts a web page to clean, readable content via Jina's r.jina.ai service. + Works without an API key, but setting JINA_API_KEY provides higher rate limits. + + Args: + url: The URL to read and extract content from. + max_content_length: Maximum content length in characters. Defaults to 10000. + target_selector: CSS selector to target specific page elements (e.g., 'article', '.main-content', '#post-body'). + return_format: Desired return format. Options: 'markdown', 'html', 'text', 'screenshot', 'pageshot'. + include_links: Whether to include links found in the page in the response metadata. Defaults to False. + + Returns: + Scraped page data with extracted content. + """ + if not url: + return ScrapeResponse(success=False, error="URL is required") + + try: + logger.info("Jina reader start url=%s include_links=%s", url, include_links) + + api_key = await resolve_credential("JINA_API_KEY") + + full_url = f"{_JINA_READER_BASE_URL}{url}" + headers = _get_jina_headers( + api_key=api_key, + target_selector=target_selector, + return_format=return_format, + include_links=include_links, + ) + + async with httpx.AsyncClient() as client: + response = await client.get(full_url, headers=headers, timeout=30.0) + response.raise_for_status() + + result = response.json() + + content = "" + title = None + metadata: dict[str, Any] | None = None + if isinstance(result, dict): + content = result.get("content", result.get("text", str(result))) or "" + title = result.get("title") + + if include_links: + links = result.get("links", result.get("linksOnPage")) + if links: + metadata = {"links": links} + else: + content = str(result) + + if max_content_length and len(content) > max_content_length: + content = content[:max_content_length] + "... (content truncated)" + + data = ScrapedPageData( + url=url, + title=title, + content=content, + markdown=None, + metadata=metadata, + ) + + logger.info("Jina reader complete url=%s content_length=%d", url, len(content)) + return ScrapeResponse(success=True, data=data) + + except Exception as e: + logger.exception("Jina reader failed") + return ScrapeResponse(success=False, error=f"Jina reader failed: {str(e)}") + + +@tool() +async def jina_search( + query: str, + max_content_length: int = _MAX_CONTENT_LENGTH, +) -> SearchResponse: + """Search the web using Jina Search API. + + Performs a web search and returns results via Jina's s.jina.ai service. + Works without an API key, but setting JINA_API_KEY provides higher rate limits. + + Args: + query: The search query. + max_content_length: Maximum content length per result. Defaults to 10000. + + Returns: + Search results with titles, URLs, and content snippets. + """ + if not query: + return SearchResponse(success=False, error="Query is required") + + try: + logger.info("Jina search start query=%s", query) + + api_key = await resolve_credential("JINA_API_KEY") + headers = _get_jina_headers(api_key=api_key) + + async with httpx.AsyncClient() as client: + response = await client.post( + _JINA_SEARCH_URL, + headers=headers, + json={"q": query}, + timeout=30.0, + ) + response.raise_for_status() + + result = response.json() + + items = [] + if isinstance(result, dict): + results_list = result.get("results", result.get("data", [])) + if isinstance(results_list, list): + for r in results_list: + if isinstance(r, dict): + content = r.get("content", r.get("description", "")) or "" + if max_content_length and len(content) > max_content_length: + content = content[:max_content_length] + "..." + items.append( + SearchResultItem( + title=r.get("title"), + url=r.get("url", "") or "", + content=content, + score=r.get("score"), + ) + ) + + data = SearchResultData(query=query, results=items) + + logger.info("Jina search complete query=%s results=%d", query, len(items)) + return SearchResponse(success=True, data=data) + + except Exception as e: + logger.exception("Jina search failed") + return SearchResponse(success=False, error=f"Jina search failed: {str(e)}") diff --git a/src/tools/web_scraping/linkup.py b/src/tools/web_scraping/linkup.py new file mode 100644 index 0000000..bd7a7b8 --- /dev/null +++ b/src/tools/web_scraping/linkup.py @@ -0,0 +1,94 @@ +"""Web search tool using Linkup API.""" + +from __future__ import annotations + +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + SearchResponse, + SearchResultData, + SearchResultItem, +) + +logger = logging.getLogger("humcp.tools.linkup") + + +@tool() +async def linkup_search( + query: str, + depth: str = "standard", + output_type: str = "searchResults", +) -> SearchResponse: + """Search the web using Linkup API for real-time information. + + Provides real-time online information using the Linkup search service. + Requires LINKUP_API_KEY. + + Args: + query: The search query. + depth: Search depth - 'standard' or 'deep'. Defaults to 'standard'. + output_type: Output type - 'searchResults' or 'sourcedAnswer'. Defaults to 'searchResults'. + + Returns: + Search results with titles, URLs, and content. + """ + try: + try: + from linkup import LinkupClient + except ImportError as err: + raise ImportError( + "linkup-sdk is required for Linkup tools. " + "Install with: pip install linkup-sdk" + ) from err + + api_key = await resolve_credential("LINKUP_API_KEY") + if not api_key: + return SearchResponse( + success=False, + error="Linkup API not configured. Set LINKUP_API_KEY.", + ) + + if not query: + return SearchResponse(success=False, error="Query is required") + + logger.info("Linkup search start query=%s depth=%s", query, depth) + + client = LinkupClient(api_key=api_key) + response = client.search( + query=query, + depth=depth, # type: ignore[arg-type] + output_type=output_type, # type: ignore[arg-type] + ) + + items = [] + if isinstance(response, str): + items.append( + SearchResultItem( + title=None, + url="", + content=response, + ) + ) + elif hasattr(response, "results"): + for r in response.results: + items.append( + SearchResultItem( + title=getattr(r, "title", None), + url=getattr(r, "url", ""), + content=getattr(r, "content", getattr(r, "snippet", "")), + score=getattr(r, "score", None), + ) + ) + + data = SearchResultData(query=query, results=items) + + logger.info("Linkup search complete query=%s results=%d", query, len(items)) + return SearchResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Linkup search failed") + return SearchResponse(success=False, error=f"Linkup search failed: {str(e)}") diff --git a/src/tools/web_scraping/newspaper.py b/src/tools/web_scraping/newspaper.py new file mode 100644 index 0000000..b4b8d35 --- /dev/null +++ b/src/tools/web_scraping/newspaper.py @@ -0,0 +1,107 @@ +"""Article extraction tool using newspaper4k (free, no API key required).""" + +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.newspaper") + + +@tool() +async def newspaper_extract( + url: str, + max_length: int | None = None, + include_nlp: bool = False, +) -> ScrapeResponse: + """Extract article content from a URL using newspaper4k. + + Parses news articles and blog posts to extract title, authors, + publish date, and main text content. Free with no API key required. + + Args: + url: The URL of the article to extract. + max_length: Optional maximum character length for the article text. + include_nlp: Whether to run NLP for keyword and summary extraction. Defaults to False. + + Returns: + Scraped page data with article text, title, top image, and metadata. + """ + if not url: + return ScrapeResponse(success=False, error="URL is required") + + try: + try: + import newspaper + except ImportError as err: + raise ImportError( + "newspaper4k is required for newspaper tools. " + "Install with: pip install newspaper4k lxml_html_clean" + ) from err + + logger.info("Newspaper extract start url=%s include_nlp=%s", url, include_nlp) + + article = newspaper.article(url) + + if not article or not article.text: + return ScrapeResponse( + success=False, + error=f"Could not extract article content from {url}", + ) + + text = article.text + if max_length and len(text) > max_length: + text = text[:max_length] + + metadata: dict = {} + if article.authors: + metadata["authors"] = article.authors + try: + if article.publish_date: + metadata["publish_date"] = article.publish_date.isoformat() + except Exception: + pass + if article.summary: + metadata["summary"] = article.summary + + top_image = getattr(article, "top_image", None) or getattr( + article, "top_img", None + ) + if top_image: + metadata["top_image"] = top_image + + if include_nlp: + try: + if hasattr(article, "nlp") and callable(article.nlp): + article.nlp() + keywords = getattr(article, "keywords", None) + if keywords: + metadata["keywords"] = keywords + nlp_summary = getattr(article, "summary", None) + if nlp_summary: + metadata["summary"] = nlp_summary + except Exception as nlp_err: + logger.warning("NLP extraction failed: %s", nlp_err) + + data = ScrapedPageData( + url=url, + title=article.title if article.title else None, + content=text, + markdown=None, + metadata=metadata if metadata else None, + ) + + logger.info( + "Newspaper extract complete url=%s content_length=%d", url, len(text) + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Newspaper extract failed") + return ScrapeResponse( + success=False, error=f"Newspaper extract failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/oxylabs.py b/src/tools/web_scraping/oxylabs.py new file mode 100644 index 0000000..65066d4 --- /dev/null +++ b/src/tools/web_scraping/oxylabs.py @@ -0,0 +1,109 @@ +"""Web scraping tool using Oxylabs Realtime API.""" + +from __future__ import annotations + +import json +import logging + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.oxylabs") + + +@tool() +async def oxylabs_scrape( + url: str, + source: str = "universal", + render_javascript: bool = False, +) -> ScrapeResponse: + """Scrape a web page using Oxylabs Realtime API. + + Uses Oxylabs' proxy infrastructure to scrape web pages reliably. + Supports multiple source types for specialized scraping. + Requires OXYLABS_USERNAME and OXYLABS_PASSWORD. + + Args: + url: The URL to scrape. + source: Scraping source type - 'universal', 'google', 'amazon', etc. Defaults to 'universal'. + render_javascript: Whether to render JavaScript before extraction. Defaults to False. + + Returns: + Scraped page data with extracted content. + """ + try: + try: + from oxylabs import RealtimeClient + except ImportError as err: + raise ImportError( + "Oxylabs SDK is required for Oxylabs tools. " + "Install with: pip install oxylabs" + ) from err + + username = await resolve_credential("OXYLABS_USERNAME") + password = await resolve_credential("OXYLABS_PASSWORD") + + if not username or not password: + return ScrapeResponse( + success=False, + error="Oxylabs credentials not configured. Set OXYLABS_USERNAME and OXYLABS_PASSWORD.", + ) + + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("Oxylabs scrape start url=%s source=%s", url, source) + + client = RealtimeClient(username, password) + + render_param = None + if render_javascript: + try: + from oxylabs.utils.types import render + + render_param = render.HTML + except ImportError: + logger.warning( + "Could not import render types; proceeding without JS rendering" + ) + + response = client.universal.scrape_url(url=url, render=render_param, parse=True) + + content = "" + title = None + metadata: dict = {"source": source, "javascript_rendered": render_javascript} + + if response.results and len(response.results) > 0: + result = response.results[0] + raw_content = result.content + + if raw_content: + if isinstance(raw_content, dict): + content = json.dumps(raw_content) + title = raw_content.get("title") + else: + content = str(raw_content) + + status_code = getattr(result, "status_code", None) + if status_code: + metadata["status_code"] = status_code + + data = ScrapedPageData( + url=url, + title=title, + content=content, + markdown=None, + metadata=metadata, + ) + + logger.info( + "Oxylabs scrape complete url=%s content_length=%d", url, len(content) + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Oxylabs scrape failed") + return ScrapeResponse(success=False, error=f"Oxylabs scrape failed: {str(e)}") diff --git a/src/tools/web_scraping/schemas.py b/src/tools/web_scraping/schemas.py new file mode 100644 index 0000000..97a9fb8 --- /dev/null +++ b/src/tools/web_scraping/schemas.py @@ -0,0 +1,233 @@ +"""Pydantic output schemas for web scraping tools.""" + +from typing import Any + +from pydantic import BaseModel, Field + +from src.humcp.schemas import ToolResponse + +# ============================================================================= +# Shared Data Models +# ============================================================================= + + +class ScrapedPageData(BaseModel): + """Data from a single scraped web page.""" + + url: str = Field(..., description="URL of the scraped page") + title: str | None = Field(default=None, description="Page title") + content: str = Field(..., description="Extracted text content") + markdown: str | None = Field(default=None, description="Content in markdown format") + metadata: dict[str, Any] | None = Field( + default=None, description="Additional metadata" + ) + + +class CrawlResultData(BaseModel): + """Data from a multi-page crawl operation.""" + + pages: list[ScrapedPageData] = Field(..., description="List of scraped pages") + total_pages: int = Field(..., description="Total number of pages crawled") + + +# ============================================================================= +# Structured Extraction Data Models +# ============================================================================= + + +class StructuredScrapeData(BaseModel): + """Data from an AI-powered structured extraction.""" + + url: str = Field(..., description="URL that was scraped") + prompt: str = Field(..., description="Extraction prompt used") + result: dict[str, Any] | str = Field( + ..., description="Structured data extracted by the LLM" + ) + + +class SearchResultItem(BaseModel): + """A single search result item.""" + + title: str | None = Field(default=None, description="Title of the result") + url: str = Field(..., description="URL of the result") + content: str = Field(..., description="Content snippet or description") + score: float | None = Field(default=None, description="Relevance score") + + +class SearchResultData(BaseModel): + """Data from a search operation.""" + + query: str = Field(..., description="The search query that was executed") + results: list[SearchResultItem] = Field(..., description="List of search results") + + +class ActorRunData(BaseModel): + """Data from an Apify actor run.""" + + actor_id: str = Field(..., description="ID of the actor that was run") + results: list[dict[str, Any]] = Field(..., description="Results from the actor run") + total_items: int = Field(..., description="Total number of result items") + + +# ============================================================================= +# Map / URL Discovery Data Models +# ============================================================================= + + +class MapResultData(BaseModel): + """Data from a URL discovery / map operation.""" + + base_url: str = Field(..., description="The base URL that was mapped") + urls: list[str] = Field(..., description="Discovered URLs") + total_urls: int = Field(..., description="Total number of discovered URLs") + + +# ============================================================================= +# Firecrawl Search Data Models +# ============================================================================= + + +class FirecrawlSearchData(BaseModel): + """Data from a Firecrawl search operation.""" + + query: str = Field(..., description="The search query that was executed") + results: list[ScrapedPageData] = Field( + ..., description="Search results with full page content" + ) + total_results: int = Field(..., description="Total number of results returned") + + +# ============================================================================= +# Spider Search Data Models +# ============================================================================= + + +class SpiderSearchData(BaseModel): + """Data from a Spider search operation.""" + + query: str = Field(..., description="The search query that was executed") + results: list[SearchResultItem] = Field(..., description="Search results") + total_results: int = Field(..., description="Total number of results returned") + + +# ============================================================================= +# Spider Links Data Models +# ============================================================================= + + +class SpiderLinksData(BaseModel): + """Data from a Spider link extraction operation.""" + + url: str = Field(..., description="The URL that was scanned for links") + links: list[str] = Field(..., description="Extracted links") + total_links: int = Field(..., description="Total number of links found") + + +# ============================================================================= +# Crawl4AI Links Data Models +# ============================================================================= + + +class LinkItem(BaseModel): + """A single extracted link with optional metadata.""" + + href: str = Field(..., description="The link URL") + text: str | None = Field(None, description="The anchor text of the link") + is_external: bool = Field( + False, description="Whether the link points to an external domain" + ) + + +class Crawl4aiLinksData(BaseModel): + """Data from a Crawl4AI link extraction operation.""" + + url: str = Field(..., description="The URL that was scanned for links") + links: list[LinkItem] = Field(..., description="Extracted links with metadata") + total_links: int = Field(..., description="Total number of links found") + internal_count: int = Field(0, description="Number of internal links") + external_count: int = Field(0, description="Number of external links") + + +# ============================================================================= +# ScrapeGraph Search Data Models +# ============================================================================= + + +class ScrapegraphSearchData(BaseModel): + """Data from a ScrapeGraph search operation.""" + + query: str = Field(..., description="The search query that was executed") + result: dict[str, Any] | str = Field( + ..., description="AI-structured search results" + ) + + +# ============================================================================= +# Response Wrappers (inheriting from ToolResponse[T]) +# ============================================================================= + + +class ScrapeResponse(ToolResponse[ScrapedPageData]): + """Response schema for single-page scrape tools.""" + + pass + + +class CrawlResponse(ToolResponse[CrawlResultData]): + """Response schema for multi-page crawl tools.""" + + pass + + +class StructuredScrapeResponse(ToolResponse[StructuredScrapeData]): + """Response schema for AI-powered structured extraction tools.""" + + pass + + +class SearchResponse(ToolResponse[SearchResultData]): + """Response schema for search tools.""" + + pass + + +class ActorRunResponse(ToolResponse[ActorRunData]): + """Response schema for Apify actor run tools.""" + + pass + + +class MapResponse(ToolResponse[MapResultData]): + """Response schema for URL discovery / map tools.""" + + pass + + +class FirecrawlSearchResponse(ToolResponse[FirecrawlSearchData]): + """Response schema for Firecrawl search tools.""" + + pass + + +class SpiderSearchResponse(ToolResponse[SpiderSearchData]): + """Response schema for Spider search tools.""" + + pass + + +class SpiderLinksResponse(ToolResponse[SpiderLinksData]): + """Response schema for Spider link extraction tools.""" + + pass + + +class Crawl4aiLinksResponse(ToolResponse[Crawl4aiLinksData]): + """Response schema for Crawl4AI link extraction tools.""" + + pass + + +class ScrapegraphSearchResponse(ToolResponse[ScrapegraphSearchData]): + """Response schema for ScrapeGraph search tools.""" + + pass diff --git a/src/tools/web_scraping/scrapegraph.py b/src/tools/web_scraping/scrapegraph.py new file mode 100644 index 0000000..a28115e --- /dev/null +++ b/src/tools/web_scraping/scrapegraph.py @@ -0,0 +1,207 @@ +"""AI-powered web scraping tool using ScrapeGraphAI.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + ScrapedPageData, + ScrapegraphSearchData, + ScrapegraphSearchResponse, + ScrapeResponse, + StructuredScrapeData, + StructuredScrapeResponse, +) + +if TYPE_CHECKING: + from scrapegraph_py import Client + +logger = logging.getLogger("humcp.tools.scrapegraph") + + +def _get_scrapegraph_client(api_key: str) -> Client: + """Create a ScrapeGraph client instance with lazy import.""" + try: + from scrapegraph_py import Client + except ImportError as err: + raise ImportError( + "scrapegraph-py is required for ScrapeGraph tools. " + "Install with: pip install scrapegraph-py" + ) from err + + return Client(api_key=api_key) + + +@tool() +async def scrapegraph_scrape( + url: str, + prompt: str, +) -> StructuredScrapeResponse: + """Extract structured data from a web page using AI with ScrapeGraphAI. + + Uses an LLM to intelligently extract specific information from a web page + based on a natural language prompt. Requires SGAI_API_KEY. + + Args: + url: The URL to scrape. + prompt: Natural language description of what data to extract. + + Returns: + Structured extraction results based on the prompt. + """ + try: + if not url: + return StructuredScrapeResponse(success=False, error="URL is required") + if not prompt: + return StructuredScrapeResponse(success=False, error="Prompt is required") + + logger.info("ScrapeGraph scrape start url=%s", url) + + api_key = await resolve_credential("SGAI_API_KEY") + if not api_key: + return StructuredScrapeResponse( + success=False, + error="ScrapeGraph API not configured. Set SGAI_API_KEY.", + ) + + client = _get_scrapegraph_client(api_key) + response = client.smartscraper(website_url=url, user_prompt=prompt) + + result = response.get("result", response) + if isinstance(result, str): + try: + result = json.loads(result) + except (json.JSONDecodeError, ValueError): + pass + + data = StructuredScrapeData( + url=url, + prompt=prompt, + result=result, + ) + + logger.info("ScrapeGraph scrape complete url=%s", url) + return StructuredScrapeResponse(success=True, data=data) + + except ValueError as e: + return StructuredScrapeResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("ScrapeGraph scrape failed") + return StructuredScrapeResponse( + success=False, error=f"ScrapeGraph scrape failed: {str(e)}" + ) + + +@tool() +async def scrapegraph_markdownify( + url: str, +) -> ScrapeResponse: + """Convert a web page to clean markdown using ScrapeGraphAI. + + Fetches a web page and converts it to well-formatted markdown. + Requires SGAI_API_KEY. + + Args: + url: The URL to convert to markdown. + + Returns: + Scraped page data with markdown content. + """ + try: + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("ScrapeGraph markdownify start url=%s", url) + + api_key = await resolve_credential("SGAI_API_KEY") + if not api_key: + return ScrapeResponse( + success=False, + error="ScrapeGraph API not configured. Set SGAI_API_KEY.", + ) + + client = _get_scrapegraph_client(api_key) + response = client.markdownify(website_url=url) + + markdown_content = response.get("result", "") + + data = ScrapedPageData( + url=url, + title=None, + content=markdown_content, + markdown=markdown_content, + ) + + logger.info( + "ScrapeGraph markdownify complete url=%s content_length=%d", + url, + len(markdown_content), + ) + return ScrapeResponse(success=True, data=data) + + except ValueError as e: + return ScrapeResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("ScrapeGraph markdownify failed") + return ScrapeResponse( + success=False, error=f"ScrapeGraph markdownify failed: {str(e)}" + ) + + +@tool() +async def scrapegraph_search( + query: str, +) -> ScrapegraphSearchResponse: + """Search the web using ScrapeGraphAI's searchscraper. + + Performs an AI-powered web search that returns structured results + based on the query. Requires SGAI_API_KEY. + + Args: + query: The search query or natural language question to answer. + + Returns: + AI-structured search results. + """ + try: + if not query: + return ScrapegraphSearchResponse(success=False, error="Query is required") + + logger.info("ScrapeGraph search start query=%s", query) + + api_key = await resolve_credential("SGAI_API_KEY") + if not api_key: + return ScrapegraphSearchResponse( + success=False, + error="ScrapeGraph API not configured. Set SGAI_API_KEY.", + ) + + client = _get_scrapegraph_client(api_key) + response = client.searchscraper(user_prompt=query) + + result = response.get("result", response) + if isinstance(result, str): + try: + result = json.loads(result) + except (json.JSONDecodeError, ValueError): + pass + + data = ScrapegraphSearchData( + query=query, + result=result, + ) + + logger.info("ScrapeGraph search complete query=%s", query) + return ScrapegraphSearchResponse(success=True, data=data) + + except ValueError as e: + return ScrapegraphSearchResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("ScrapeGraph search failed") + return ScrapegraphSearchResponse( + success=False, error=f"ScrapeGraph search failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/spider.py b/src/tools/web_scraping/spider.py new file mode 100644 index 0000000..f5d3038 --- /dev/null +++ b/src/tools/web_scraping/spider.py @@ -0,0 +1,292 @@ +"""Web scraping and crawling tools using Spider API.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from src.humcp.credentials import resolve_credential +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ( + CrawlResponse, + CrawlResultData, + ScrapedPageData, + ScrapeResponse, + SearchResultItem, + SpiderLinksData, + SpiderLinksResponse, + SpiderSearchData, + SpiderSearchResponse, +) + +if TYPE_CHECKING: + from spider import Spider as ExternalSpider + +logger = logging.getLogger("humcp.tools.spider") + + +def _get_spider_client(api_key: str) -> ExternalSpider: + """Create a Spider client instance with lazy import.""" + try: + from spider import Spider as ExternalSpider + except ImportError as err: + raise ImportError( + "spider-client is required for Spider tools. " + "Install with: pip install spider-client" + ) from err + + return ExternalSpider(api_key=api_key) + + +@tool() +async def spider_scrape(url: str) -> ScrapeResponse: + """Scrape a web page and return its content as markdown using Spider. + + Args: + url: The URL of the page to scrape. + + Returns: + Scraped page data with markdown content. + """ + try: + if not url: + return ScrapeResponse(success=False, error="URL is required") + + logger.info("Spider scrape start url=%s", url) + + api_key = await resolve_credential("SPIDER_API_KEY") + if not api_key: + return ScrapeResponse( + success=False, + error="Spider API not configured. Set SPIDER_API_KEY.", + ) + + app = _get_spider_client(api_key) + options = {"return_format": "markdown"} + results = app.scrape_url(url, options) + + content = "" + if isinstance(results, list) and len(results) > 0: + first = results[0] + content = ( + first.get("content", "") if isinstance(first, dict) else str(first) + ) + elif isinstance(results, dict): + content = results.get("content", json.dumps(results)) + else: + content = str(results) + + data = ScrapedPageData( + url=url, + title=None, + content=content, + markdown=content, + ) + + logger.info( + "Spider scrape complete url=%s content_length=%d", url, len(content) + ) + return ScrapeResponse(success=True, data=data) + + except ValueError as e: + return ScrapeResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Spider scrape failed") + return ScrapeResponse(success=False, error=f"Spider scrape failed: {str(e)}") + + +@tool() +async def spider_crawl( + url: str, + limit: int = 10, + depth: int | None = None, +) -> CrawlResponse: + """Crawl a website starting from a URL using Spider. + + Discovers and scrapes multiple pages, returning content as markdown. + + Args: + url: The starting URL to crawl from. + limit: Maximum number of pages to crawl. Defaults to 10. + depth: Maximum crawl depth from the starting URL. None means no depth limit. + + Returns: + Crawl results with data from all discovered pages. + """ + try: + if not url: + return CrawlResponse(success=False, error="URL is required") + + logger.info("Spider crawl start url=%s limit=%d depth=%s", url, limit, depth) + + api_key = await resolve_credential("SPIDER_API_KEY") + if not api_key: + return CrawlResponse( + success=False, + error="Spider API not configured. Set SPIDER_API_KEY.", + ) + + app = _get_spider_client(api_key) + options: dict = {"return_format": "markdown", "limit": limit} + if depth is not None: + options["depth"] = depth + + results = app.crawl_url(url, options) + + pages = [] + if isinstance(results, list): + for item in results: + page_content = ( + item.get("content", "") if isinstance(item, dict) else str(item) + ) + page_url = item.get("url", url) if isinstance(item, dict) else url + pages.append( + ScrapedPageData( + url=page_url, + title=None, + content=page_content, + markdown=page_content, + ) + ) + + data = CrawlResultData(pages=pages, total_pages=len(pages)) + + logger.info("Spider crawl complete url=%s pages=%d", url, len(pages)) + return CrawlResponse(success=True, data=data) + + except ValueError as e: + return CrawlResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Spider crawl failed") + return CrawlResponse(success=False, error=f"Spider crawl failed: {str(e)}") + + +@tool() +async def spider_search( + query: str, + limit: int = 5, +) -> SpiderSearchResponse: + """Search the web using Spider's search endpoint. + + Performs a web search and returns results with content snippets. + + Args: + query: The search query to execute. + limit: Maximum number of results to return. Defaults to 5. + + Returns: + Search results with titles, URLs, and content snippets. + """ + try: + if not query: + return SpiderSearchResponse(success=False, error="Query is required") + + logger.info("Spider search start query=%s limit=%d", query, limit) + + api_key = await resolve_credential("SPIDER_API_KEY") + if not api_key: + return SpiderSearchResponse( + success=False, + error="Spider API not configured. Set SPIDER_API_KEY.", + ) + + app = _get_spider_client(api_key) + options: dict = {"limit": limit, "return_format": "markdown"} + results = app.search(query, options) + + items: list[SearchResultItem] = [] + if isinstance(results, list): + for item in results: + if isinstance(item, dict): + items.append( + SearchResultItem( + title=item.get("title"), + url=item.get("url", ""), + content=item.get("content", item.get("description", "")) + or "", + score=item.get("score"), + ) + ) + + data = SpiderSearchData( + query=query, + results=items, + total_results=len(items), + ) + + logger.info("Spider search complete query=%s results=%d", query, len(items)) + return SpiderSearchResponse(success=True, data=data) + + except ValueError as e: + return SpiderSearchResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Spider search failed") + return SpiderSearchResponse( + success=False, error=f"Spider search failed: {str(e)}" + ) + + +@tool() +async def spider_links( + url: str, + limit: int = 100, +) -> SpiderLinksResponse: + """Extract all links from a web page using Spider. + + Discovers and returns all outbound links found on the specified URL. + + Args: + url: The URL to extract links from. + limit: Maximum number of links to return. Defaults to 100. + + Returns: + List of extracted links from the page. + """ + try: + if not url: + return SpiderLinksResponse(success=False, error="URL is required") + + logger.info("Spider links start url=%s limit=%d", url, limit) + + api_key = await resolve_credential("SPIDER_API_KEY") + if not api_key: + return SpiderLinksResponse( + success=False, + error="Spider API not configured. Set SPIDER_API_KEY.", + ) + + app = _get_spider_client(api_key) + options: dict = {"limit": limit} + results = app.links(url, options) + + links: list[str] = [] + if isinstance(results, list): + for item in results: + if isinstance(item, dict): + page_url = item.get("url", "") + if page_url: + links.append(page_url) + elif isinstance(item, str): + links.append(item) + elif isinstance(results, dict): + raw_links = results.get("links", results.get("urls", [])) + if isinstance(raw_links, list): + links = [str(link) for link in raw_links] + + data = SpiderLinksData( + url=url, + links=links, + total_links=len(links), + ) + + logger.info("Spider links complete url=%s links=%d", url, len(links)) + return SpiderLinksResponse(success=True, data=data) + + except ValueError as e: + return SpiderLinksResponse(success=False, error=str(e)) + except Exception as e: + logger.exception("Spider links failed") + return SpiderLinksResponse( + success=False, error=f"Spider links failed: {str(e)}" + ) diff --git a/src/tools/web_scraping/trafilatura.py b/src/tools/web_scraping/trafilatura.py new file mode 100644 index 0000000..38fc0fc --- /dev/null +++ b/src/tools/web_scraping/trafilatura.py @@ -0,0 +1,115 @@ +"""Web content extraction tool using trafilatura (free, no API key required).""" + +from __future__ import annotations + +import logging + +from src.humcp.decorator import tool +from src.tools.web_scraping.schemas import ScrapedPageData, ScrapeResponse + +logger = logging.getLogger("humcp.tools.trafilatura") + + +@tool() +async def trafilatura_extract( + url: str, + include_comments: bool = False, + output_format: str = "txt", + include_links: bool = False, + include_images: bool = False, + favor_precision: bool = False, +) -> ScrapeResponse: + """Extract main text content from a web page using trafilatura. + + Trafilatura is optimized for extracting the main content from web pages, + filtering out boilerplate, navigation, and other non-content elements. + Free to use with no API key required. + + Args: + url: The URL to extract content from. + include_comments: Whether to include comments in the extraction. Defaults to False. + output_format: Output format: 'txt', 'markdown', 'xml', 'json'. Defaults to 'txt'. + include_links: Whether to keep links/references in the extracted text. Defaults to False. + include_images: Whether to include image references in the output. Defaults to False. + favor_precision: Whether to favor precision over recall in extraction (stricter filtering). Defaults to False. + + Returns: + Scraped page data with extracted content. + """ + if not url: + return ScrapeResponse(success=False, error="URL is required") + + try: + try: + from trafilatura import extract, extract_metadata, fetch_url + from trafilatura.meta import reset_caches + except ImportError as err: + raise ImportError( + "trafilatura is required for trafilatura tools. " + "Install with: pip install trafilatura" + ) from err + + logger.info("Trafilatura extract start url=%s", url) + + html_content = fetch_url(url) + if not html_content: + return ScrapeResponse( + success=False, + error=f"Could not fetch content from URL: {url}", + ) + + result = extract( + html_content, + url=url, + include_comments=include_comments, + include_tables=True, + include_links=include_links, + include_images=include_images, + favor_precision=favor_precision, + output_format=output_format, + ) + + if result is None: + reset_caches() + return ScrapeResponse( + success=False, + error=f"Could not extract readable content from URL: {url}", + ) + + # Extract metadata + metadata_doc = extract_metadata(html_content, default_url=url) + metadata = None + title = None + if metadata_doc: + metadata_dict = metadata_doc.as_dict() + title = metadata_dict.get("title") + metadata = { + k: v for k, v in metadata_dict.items() if v is not None and k != "title" + } + + reset_caches() + + markdown = result if output_format == "markdown" else None + + data = ScrapedPageData( + url=url, + title=title, + content=result, + markdown=markdown, + metadata=metadata if metadata else None, + ) + + logger.info( + "Trafilatura extract complete url=%s content_length=%d", + url, + len(result), + ) + return ScrapeResponse(success=True, data=data) + + except ImportError: + raise + except Exception as e: + logger.exception("Trafilatura extract failed") + return ScrapeResponse( + success=False, error=f"Trafilatura extract failed: {str(e)}" + ) diff --git a/tests/humcp/test_auth.py b/tests/humcp/test_auth.py new file mode 100644 index 0000000..5cac6dd --- /dev/null +++ b/tests/humcp/test_auth.py @@ -0,0 +1,74 @@ +"""Tests for humcp auth module.""" + +from uuid import UUID + +from src.humcp.auth import ( + _current_user_id, + get_current_user_id, + has_google_credentials, + is_auth_enabled, +) + + +class TestIsAuthEnabled: + """Tests for is_auth_enabled function.""" + + def test_is_auth_enabled_true(self, monkeypatch): + """AUTH_ENABLED=true should return True.""" + monkeypatch.setenv("AUTH_ENABLED", "true") + assert is_auth_enabled() is True + + def test_is_auth_enabled_false(self, monkeypatch): + """AUTH_ENABLED=false should return False.""" + monkeypatch.setenv("AUTH_ENABLED", "false") + assert is_auth_enabled() is False + + def test_is_auth_enabled_default(self, monkeypatch): + """No AUTH_ENABLED env var should default to True.""" + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert is_auth_enabled() is True + + def test_is_auth_enabled_case_insensitive(self, monkeypatch): + """AUTH_ENABLED=TRUE (uppercase) should return True.""" + monkeypatch.setenv("AUTH_ENABLED", "TRUE") + assert is_auth_enabled() is True + + +class TestHasGoogleCredentials: + """Tests for has_google_credentials function.""" + + def test_has_google_credentials_missing(self, monkeypatch): + """Returns False when credentials are not set.""" + monkeypatch.delenv("GOOGLE_OAUTH_CLIENT_ID", raising=False) + monkeypatch.delenv("GOOGLE_OAUTH_CLIENT_SECRET", raising=False) + assert has_google_credentials() is False + + def test_has_google_credentials_set(self, monkeypatch): + """Returns True when both client ID and secret are set.""" + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "test-client-id") + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "test-client-secret") + assert has_google_credentials() is True + + +class TestGetCurrentUserId: + """Tests for get_current_user_id function.""" + + def test_get_current_user_id_none(self): + """Returns None when no context is set.""" + token = _current_user_id.set(None) + try: + result = get_current_user_id() + assert result is None + finally: + _current_user_id.reset(token) + + def test_get_current_user_id_from_contextvar(self): + """Returns UUID from the ContextVar when set.""" + test_uuid = "12345678-1234-5678-1234-567812345678" + token = _current_user_id.set(test_uuid) + try: + result = get_current_user_id() + assert result == UUID(test_uuid) + assert isinstance(result, UUID) + finally: + _current_user_id.reset(token) diff --git a/tests/humcp/test_config.py b/tests/humcp/test_config.py index 22f6f55..c0a0d4a 100644 --- a/tests/humcp/test_config.py +++ b/tests/humcp/test_config.py @@ -25,7 +25,7 @@ def make_registered_tool(name: str, category: str) -> RegisteredTool: mock_tool.description = f"Description for {name}" mock_tool.parameters = {"type": "object", "properties": {}} mock_tool.fn = lambda: None - return RegisteredTool(tool=mock_tool, category=category) + return RegisteredTool(tool=mock_tool, category=category, app="test_app") # Test fixtures diff --git a/tests/humcp/test_middleware.py b/tests/humcp/test_middleware.py new file mode 100644 index 0000000..fd46296 --- /dev/null +++ b/tests/humcp/test_middleware.py @@ -0,0 +1,110 @@ +"""Tests for humcp middleware module.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _create_test_app(service_api_key: str = "") -> FastAPI: + """Create a minimal FastAPI app with APIKeyMiddleware for testing.""" + # We need to patch the module-level SERVICE_API_KEY before adding middleware, + # so we import and patch at call time. + import src.humcp.middleware as mw_module + + original_key = mw_module.SERVICE_API_KEY + mw_module.SERVICE_API_KEY = service_api_key + + app = FastAPI() + app.add_middleware(mw_module.APIKeyMiddleware) + + @app.get("/") + async def root(): + return {"message": "root"} + + @app.get("/docs") + async def docs(): + return {"message": "docs"} + + @app.get("/openapi.json") + async def openapi(): + return {"message": "openapi"} + + @app.get("/tools") + async def tools(): + return {"message": "tools"} + + @app.get("/tools/test") + async def tools_test(): + return {"message": "tools_test"} + + # Restore original key after app creation (middleware captures reference to module) + # We rely on the middleware reading mw_module.SERVICE_API_KEY at dispatch time + return app, mw_module, original_key + + +class TestAPIKeyMiddleware: + """Tests for APIKeyMiddleware.""" + + def test_public_paths_bypass_auth(self): + """Requests to /, /docs, /openapi.json should pass without API key.""" + app, mw_module, original_key = _create_test_app(service_api_key="secret-key") + try: + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/") + assert resp.status_code == 200 + + resp = client.get("/docs") + assert resp.status_code == 200 + + resp = client.get("/openapi.json") + assert resp.status_code == 200 + finally: + mw_module.SERVICE_API_KEY = original_key + + def test_missing_api_key_returns_401(self): + """When SERVICE_API_KEY is set, request without key to /tools returns 401.""" + app, mw_module, original_key = _create_test_app(service_api_key="secret-key") + try: + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/tools") + assert resp.status_code == 401 + finally: + mw_module.SERVICE_API_KEY = original_key + + def test_valid_api_key_passes(self): + """Request with correct X-API-Key header should pass.""" + app, mw_module, original_key = _create_test_app(service_api_key="secret-key") + try: + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/tools", headers={"X-API-Key": "secret-key"}) + assert resp.status_code == 200 + assert resp.json()["message"] == "tools" + finally: + mw_module.SERVICE_API_KEY = original_key + + def test_invalid_api_key_returns_401(self): + """Wrong API key should return 401.""" + app, mw_module, original_key = _create_test_app(service_api_key="secret-key") + try: + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/tools", headers={"X-API-Key": "wrong-key"}) + assert resp.status_code == 401 + finally: + mw_module.SERVICE_API_KEY = original_key + + def test_no_api_key_configured_passes(self): + """When SERVICE_API_KEY is not set, all requests should pass.""" + app, mw_module, original_key = _create_test_app(service_api_key="") + try: + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/tools") + assert resp.status_code == 200 + + resp = client.get("/tools/test") + assert resp.status_code == 200 + finally: + mw_module.SERVICE_API_KEY = original_key diff --git a/tests/humcp/test_permissions.py b/tests/humcp/test_permissions.py new file mode 100644 index 0000000..9dc5515 --- /dev/null +++ b/tests/humcp/test_permissions.py @@ -0,0 +1,57 @@ +"""Tests for humcp permissions module.""" + +import logging + +import pytest +from fastapi import HTTPException + +from src.humcp.permissions import check_permission, require_auth + + +class TestRequireAuth: + """Tests for require_auth function.""" + + @pytest.mark.asyncio + async def test_require_auth_no_user_no_strict(self, monkeypatch): + """Returns None in dev mode (TOOLSET_REQUIRE_AUTH not set).""" + monkeypatch.setattr("src.humcp.permissions.TOOLSET_REQUIRE_AUTH", False) + # Ensure no user is set + monkeypatch.setattr("src.humcp.permissions.get_current_user_id", lambda: None) + result = await require_auth() + assert result is None + + @pytest.mark.asyncio + async def test_require_auth_no_user_strict(self, monkeypatch): + """Raises HTTPException 401 when TOOLSET_REQUIRE_AUTH=true and no user.""" + monkeypatch.setattr("src.humcp.permissions.TOOLSET_REQUIRE_AUTH", True) + monkeypatch.setattr("src.humcp.permissions.get_current_user_id", lambda: None) + with pytest.raises(HTTPException) as exc_info: + await require_auth() + assert exc_info.value.status_code == 401 + + +class TestCheckPermission: + """Tests for check_permission function.""" + + @pytest.mark.asyncio + async def test_check_permission_logs_warning(self, monkeypatch, caplog): + """Verify warning is logged about stub permission check.""" + monkeypatch.setattr("src.humcp.permissions.STRICT_PERMISSIONS", False) + monkeypatch.setattr("src.humcp.permissions.TOOLSET_REQUIRE_AUTH", False) + monkeypatch.setattr("src.humcp.permissions.get_current_user_id", lambda: None) + + with caplog.at_level(logging.WARNING, logger="src.humcp.permissions"): + await check_permission("document", "doc-123", "viewer") + + assert any( + "Permission check stub" in record.message for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_check_permission_strict_raises_403(self, monkeypatch): + """Raises 403 when STRICT_PERMISSIONS=true.""" + monkeypatch.setattr("src.humcp.permissions.STRICT_PERMISSIONS", True) + + with pytest.raises(HTTPException) as exc_info: + await check_permission("document", "doc-123", "viewer") + assert exc_info.value.status_code == 403 diff --git a/tests/humcp/test_routes.py b/tests/humcp/test_routes.py index d8b27d0..ad53a96 100644 --- a/tests/humcp/test_routes.py +++ b/tests/humcp/test_routes.py @@ -25,7 +25,7 @@ def make_registered_tool( mock_tool.parameters = parameters mock_tool.output_schema = None mock_tool.fn = fn - return RegisteredTool(tool=mock_tool, category=category) + return RegisteredTool(tool=mock_tool, category=category, app="test_app") @pytest.fixture diff --git a/uv.lock b/uv.lock index d5ef27a..6835c2f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,123 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiohttp-retry" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/ebda4d8e3d8cfa1fd3db0fb428db2dd7461d5742cea35178277ad180b033/aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1", size = 13608, upload-time = "2024-11-06T10:44:54.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] @@ -24,6 +139,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -36,6 +170,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "apify-client" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apify-shared" }, + { name = "colorama" }, + { name = "impit" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/6a/b872d6bbc84c6aaf27b455492c6ff1bd057fea302c5d40619c733d48a718/apify_client-2.5.0.tar.gz", hash = "sha256:daa2af6a50e573f78bd46a4728a3f2be76cee93cf5c4ff9d0fd38b6756792689", size = 377916, upload-time = "2026-02-18T13:03:16.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/82/4fe19adfa6b962ab8a740782b6246b7c499f13edccac24733f015d895725/apify_client-2.5.0-py3-none-any.whl", hash = "sha256:4aa6172bed92d83f2d2bbe1f95cfaab2e147a834dfa007e309fd0b4709423316", size = 86996, upload-time = "2026-02-18T13:03:14.891Z" }, +] + +[[package]] +name = "apify-shared" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/88/8833a8bba9044ce134bb2e57fbb626f1ddbeecac964bc2e2b652a50fadd1/apify_shared-2.2.0.tar.gz", hash = "sha256:ad48a96084e3c38faa1bac723a47929a1bb2c771544da2f0cb503eabdecfc79a", size = 45534, upload-time = "2026-01-15T10:17:14.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7c/9607852e2bb324fa40a5b967e162dea1b3c76b429cf90b602e4a202c101a/apify_shared-2.2.0-py3-none-any.whl", hash = "sha256:667d4d00ac3cf8091702640547387ac5c72a1df402bbb3923f7a401bc25d9d50", size = 16408, upload-time = "2026-01-15T10:17:13.103Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "arize-otel" version = "0.11.0" @@ -71,6 +272,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/be/e7ddb54c4ad6115d2d468b71e90d7a2718735fd217f05c50759799191bfe/arize_phoenix_otel-0.14.0-py3-none-any.whl", hash = "sha256:47bf5563b9342a931385a16609ca83ada44d56a00bf6ed3be199226792b9937f", size = 17708, upload-time = "2025-11-19T19:48:28.252Z" }, ] +[[package]] +name = "arxiv" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "feedparser" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/6e/647dd134e66d3ea6ff8aba2a177a37c74245625cfc58184e3aff99c8d8ec/arxiv-2.4.1.tar.gz", hash = "sha256:691606c1069bcca8316fcb082f5d15e65f1f24a021b0b87f01b9fa56347f63c8", size = 74975, upload-time = "2026-03-04T03:05:33.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/6a/297380dc42fa25dff095feda66d46f7abba77ba54579d079071a2459e8d3/arxiv-2.4.1-py3-none-any.whl", hash = "sha256:060d678410ffc224ada01089f877b7676f250e37f96c140bad6c287afadb15d8", size = 12106, upload-time = "2026-03-04T03:05:33.029Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "atlassian-python-api" +version = "4.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "deprecated" }, + { name = "jmespath" }, + { name = "oauthlib" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/e8/f23b7273e410c6fe9f98f9db25268c6736572f22a9566d1dc9ed3614bb68/atlassian_python_api-4.0.7.tar.gz", hash = "sha256:8d9cc6068b1d2a48eb434e22e57f6bbd918a47fac9e46b95b7a3cefb00fceacb", size = 271149, upload-time = "2025-08-21T13:19:40.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/83/e4f9976ce3c933a079b8931325e7a9c0a8bba7030a2cb85764c0048f3479/atlassian_python_api-4.0.7-py3-none-any.whl", hash = "sha256:46a70cb29eaab87c0a1697fccd3e25df1aa477e6aa4fb9ba936a9d46b425933c", size = 197746, upload-time = "2025-08-21T13:19:39.044Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -191,6 +455,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "baidusearch" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "lxml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/c7/5e111f7d6b87ab5169665e4cb80aff1bfcd94c07045b02d217711f1638bb/baidusearch-1.0.3.tar.gz", hash = "sha256:b34648dd3370a953cf0ee5f9a9164154d46c2490518e4440d349ff77ff87a07b", size = 22574, upload-time = "2024-11-14T03:11:26.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/f0/e5143bc695528069089260b2c3d19a9fe160bf9b55477eda7da1bd96e44f/baidusearch-1.0.3-py2.py3-none-any.whl", hash = "sha256:d915b06d0ff54c71572a82ee2850d9bd7f6451e67506279ef4e65fc40728367a", size = 14293, upload-time = "2024-11-14T03:11:22.002Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f8/442c495790e1a1403005c68c9c05bae040cd2e21c973abb86b272b5fcfbc/baidusearch-1.0.3-py3-none-any.whl", hash = "sha256:179a0f11f52c72645d7b5c3e1e3c9cbdf2f086dba12bf274f7da48621d8f0e03", size = 14288, upload-time = "2024-11-14T03:11:23.531Z" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -213,6 +510,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "bitarray" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/06/92fdc84448d324ab8434b78e65caf4fb4c6c90b4f8ad9bdd4c8021bfaf1e/bitarray-3.8.0.tar.gz", hash = "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", size = 151991, upload-time = "2025-11-02T21:41:15.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/35/480364d4baf1e34c79076750914664373f561c58abb5c31c35b3fae613ff/bitarray-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", size = 148582, upload-time = "2025-11-02T21:39:42.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a8/718b95524c803937f4edbaaf6480f39c80f6ed189d61357b345e8361ffb6/bitarray-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", size = 145433, upload-time = "2025-11-02T21:39:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/03/66/4a10f30dc9e2e01e3b4ecd44a511219f98e63c86b0e0f704c90fac24059b/bitarray-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", size = 332986, upload-time = "2025-11-02T21:39:44.656Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/4c08774d847f80a1166e4c704b4e0f1c417c0afe6306eae0bc5e70d35faa/bitarray-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", size = 360634, upload-time = "2025-11-02T21:39:45.798Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/bf8ad26169ebd0b2746d5c7564db734453ca467f8aab87e9d43b0a794383/bitarray-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", size = 371992, upload-time = "2025-11-02T21:39:46.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/16/ce166754e7c9d10650e02914552fa637cf3b2591f7ed16632bbf6b783312/bitarray-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", size = 340315, upload-time = "2025-11-02T21:39:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/de/2a/fbba3a106ddd260e84b9a624f730257c32ba51a8a029565248dfedfdf6f2/bitarray-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", size = 330473, upload-time = "2025-11-02T21:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/68/97/56cf3c70196e7307ad32318a9d6ed969dbdc6a4534bbe429112fa7dfe42e/bitarray-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", size = 358129, upload-time = "2025-11-02T21:39:51.189Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/afd391a5c0896d3339613321b2f94af853f29afc8bd3fbc327431244c642/bitarray-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", size = 356005, upload-time = "2025-11-02T21:39:52.355Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a8e1a371babba29bad3378bb3a2cdca2b012170711e7fe1f22031a6b7b95/bitarray-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", size = 336862, upload-time = "2025-11-02T21:39:54.345Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/6dc1d0fdc06991c8dc3b1fcfe1ae49fbaced42064cd1b5f24278e73fe05f/bitarray-3.8.0-cp313-cp313-win32.whl", hash = "sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", size = 143018, upload-time = "2025-11-02T21:39:56.361Z" }, + { url = "https://files.pythonhosted.org/packages/2e/72/76e13f5cd23b8b9071747909663ce3b02da24a5e7e22c35146338625db35/bitarray-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", size = 149977, upload-time = "2025-11-02T21:39:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/01/37/60f336c32336cc3ec03b0c61076f16ea2f05d5371c8a56e802161d218b77/bitarray-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", size = 146930, upload-time = "2025-11-02T21:39:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b0/411327a6c7f6b2bead64bb06fe60b92e0344957ec1ab0645d5ccc25fdafe/bitarray-3.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", size = 148563, upload-time = "2025-11-02T21:40:01.006Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bc/ff80d97c627d774f879da0ea93223adb1267feab7e07d5c17580ffe6d632/bitarray-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", size = 145422, upload-time = "2025-11-02T21:40:02.535Z" }, + { url = "https://files.pythonhosted.org/packages/66/e7/b4cb6c5689aacd0a32f3aa8a507155eaa33528c63de2f182b60843fbf700/bitarray-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", size = 332852, upload-time = "2025-11-02T21:40:03.645Z" }, + { url = "https://files.pythonhosted.org/packages/e7/91/fbd1b047e3e2f4b65590f289c8151df1d203d75b005f5aae4e072fe77d76/bitarray-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", size = 360801, upload-time = "2025-11-02T21:40:04.827Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4a/63064c593627bac8754fdafcb5343999c93ab2aeb27bcd9d270a010abea5/bitarray-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", size = 371408, upload-time = "2025-11-02T21:40:05.985Z" }, + { url = "https://files.pythonhosted.org/packages/46/97/ddc07723767bdafd170f2ff6e173c940fa874192783ee464aa3c1dedf07d/bitarray-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", size = 340033, upload-time = "2025-11-02T21:40:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/e1ea9f1146fd4af032817069ff118918d73e5de519854ce3860e2ed560ff/bitarray-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", size = 330774, upload-time = "2025-11-02T21:40:08.496Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9f/8242296c124a48d1eab471fd0838aeb7ea9c6fd720302d99ab7855d3e6d3/bitarray-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", size = 358337, upload-time = "2025-11-02T21:40:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6b/9095d75264c67d479f298c80802422464ce18c3cdd893252eeccf4997611/bitarray-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", size = 355639, upload-time = "2025-11-02T21:40:11.485Z" }, + { url = "https://files.pythonhosted.org/packages/a0/af/c93c0ae5ef824136e90ac7ddf6cceccb1232f34240b2f55a922f874da9b4/bitarray-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", size = 336999, upload-time = "2025-11-02T21:40:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/81/0f/72c951f5997b2876355d5e671f78dd2362493254876675cf22dbd24389ae/bitarray-3.8.0-cp314-cp314-win32.whl", hash = "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", size = 142169, upload-time = "2025-11-02T21:40:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/ef1b4de8107bf13823da8756c20e1fbc9452228b4e837f46f6d9ddba3eb3/bitarray-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", size = 148737, upload-time = "2025-11-02T21:40:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/bc0784136775024ac56cc67c0d6f9aa77a7770de7f82c3a7c9be11c217cd/bitarray-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", size = 146083, upload-time = "2025-11-02T21:40:17.135Z" }, + { url = "https://files.pythonhosted.org/packages/6e/64/57984e64264bf43d93a1809e645972771566a2d0345f4896b041ce20b000/bitarray-3.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", size = 149455, upload-time = "2025-11-02T21:40:18.558Z" }, + { url = "https://files.pythonhosted.org/packages/81/c0/0d5f2eaef1867f462f764bdb07d1e116c33a1bf052ea21889aefe4282f5b/bitarray-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", size = 146491, upload-time = "2025-11-02T21:40:19.665Z" }, + { url = "https://files.pythonhosted.org/packages/65/c6/bc1261f7a8862c0c59220a484464739e52235fd1e2afcb24d7f7d3fb5702/bitarray-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", size = 339721, upload-time = "2025-11-02T21:40:21.277Z" }, + { url = "https://files.pythonhosted.org/packages/81/d8/289ca55dd2939ea17b1108dc53bffc0fdc5160ba44f77502dfaae35d08c6/bitarray-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", size = 367823, upload-time = "2025-11-02T21:40:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/91/a2/61e7461ca9ac0fcb70f327a2e84b006996d2a840898e69037a39c87c6d06/bitarray-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", size = 377341, upload-time = "2025-11-02T21:40:23.789Z" }, + { url = "https://files.pythonhosted.org/packages/6c/87/4a0c9c8bdb13916d443e04d8f8542eef9190f31425da3c17c3478c40173f/bitarray-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", size = 344985, upload-time = "2025-11-02T21:40:25.261Z" }, + { url = "https://files.pythonhosted.org/packages/17/4c/ff9259b916efe53695b631772e5213699c738efc2471b5ffe273f4000994/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", size = 336796, upload-time = "2025-11-02T21:40:26.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4b/51b2468bbddbade5e2f3b8d5db08282c5b309e8687b0f02f75a8b5ff559c/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", size = 365085, upload-time = "2025-11-02T21:40:28.224Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/53473bfc2e052c6dbb628cdc1b156be621c77aaeb715918358b01574be55/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", size = 361012, upload-time = "2025-11-02T21:40:29.635Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/242bf2e44bfc69e73fa2b954b425d761a8e632f78ea31008f1c3cfad0854/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", size = 340644, upload-time = "2025-11-02T21:40:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/cf/01/12e5ecf30a5de28a32485f226cad4b8a546845f65f755ce0365057ab1e92/bitarray-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", size = 143630, upload-time = "2025-11-02T21:40:32.351Z" }, + { url = "https://files.pythonhosted.org/packages/b6/92/6b6ade587b08024a8a890b07724775d29da9cf7497be5c3cbe226185e463/bitarray-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", size = 150250, upload-time = "2025-11-02T21:40:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/be3858ffed004e47e48a2cefecdbf9b950d41098b780f9dc3aa609a88351/bitarray-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", size = 147015, upload-time = "2025-11-02T21:40:35.064Z" }, +] + [[package]] name = "black" version = "25.12.0" @@ -240,6 +584,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, ] +[[package]] +name = "boto3" +version = "1.42.63" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/2a/33d5d4b16fd97dfd629421ebed2456392eae1553cc401d9f86010c18065e/boto3-1.42.63.tar.gz", hash = "sha256:cd008cfd0d7ea30f1c5e22daf0998c55b7c6c68cb68eea05110e33fe641173d5", size = 112778, upload-time = "2026-03-06T22:47:55.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/19/f1d8d2b24871d3d0ccb2cbd0b0cb64a3396d439384bd9643d2c25c641b84/boto3-1.42.63-py3-none-any.whl", hash = "sha256:d502a89a0acc701692ae020d15981f2a82e9eb3485acc651cfd0cf1a3afe79ee", size = 140554, upload-time = "2026-03-06T22:47:53.463Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.63" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/eb/a1c042f6638ada552399a9977335a6de2668a85bf80bece193c953531236/botocore-1.42.63.tar.gz", hash = "sha256:1fdfc33cff58d21e8622cf620ba2bba3cff324557932aaf935b5374e4610f059", size = 14965362, upload-time = "2026-03-06T22:47:44.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/60/17a2d3b94658bb999c6aee7bba6c76b271905debf0c8c8e6ac63ca8491bc/botocore-1.42.63-py3-none-any.whl", hash = "sha256:83f39d04f2b316bdfc59a3cac2d12238bde7126ac99d9a57d910dbd86d58c528", size = 14639889, upload-time = "2026-03-06T22:47:39.347Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "cachetools" version = "6.2.4" @@ -249,6 +658,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + +[[package]] +name = "cartesia" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/65/5c2085821842b334a43025becbb70c52d02a458c0b24f9c1a0912d7e7e13/cartesia-3.0.2.tar.gz", hash = "sha256:beeff16cd5a36d30d908984e84591e6e7c20429e54c5822fca5c16bf26dd805a", size = 584593, upload-time = "2026-02-26T01:34:46.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/db/4e7ddf0a31c4c94fa5badd6b1b4fc52733f8bb8b98e4c53293a467b95149/cartesia-3.0.2-py3-none-any.whl", hash = "sha256:ec19decf77cac350a1e38872d50213daecd51839afb522011d68a49dcae6f1b5", size = 170183, upload-time = "2026-02-26T01:34:45.011Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -353,6 +796,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "ckzg" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b8/9add33a0be636e2d4467ea4497b47e124677a0478d9be40ef6473d4ec29b/ckzg-2.1.6.tar.gz", hash = "sha256:49df31684283dfcfd1eeca638d84c03788ebdd48e8afc0643bf5188ec023dc8d", size = 1127792, upload-time = "2026-02-26T17:19:49.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/34/0cc58fa7907ea5c3961f6c9dd086b2d75ffb7897aeff4baddf1ee868ac60/ckzg-2.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:616cd69938d0d79b13e128f4706ea48c21866c3f7c52547d4f185837d5568d69", size = 96390, upload-time = "2026-02-26T17:19:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/dc6a25d3ba37531e2b9838ad875d061348685b50ff6759261c9831942a77/ckzg-2.1.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:8d3056cd48f97041f98b73404f397c29aebd04b7f8f3bbc012180680d295a464", size = 180486, upload-time = "2026-02-26T17:19:02.768Z" }, + { url = "https://files.pythonhosted.org/packages/d5/95/17c7407af8a5070cf05ed8ff1156d9b62babecf74c84b2d61ed03efc72a2/ckzg-2.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c732e429b50dee04cd51fb601fc9cb4ba4d853e2e29a9914b3fdd36b576b0211", size = 166304, upload-time = "2026-02-26T17:19:03.825Z" }, + { url = "https://files.pythonhosted.org/packages/9a/31/8d7012523edea81d54f2f634f512f3a0705dd3dca99fdfe1281b09bc96ca/ckzg-2.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0f9933b6e06e6560b4b8980e2385ec4d639cfdebb03bffaadde75a5c61edb45", size = 176058, upload-time = "2026-02-26T17:19:04.879Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/f1a73fee7b2b2212691acf2231a8df717b19f95412ca236549f4d4a21932/ckzg-2.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:be65a7c00d445cf07adea7679842df469989e6790df1d846944f9885a4a788be", size = 173687, upload-time = "2026-02-26T17:19:05.919Z" }, + { url = "https://files.pythonhosted.org/packages/03/ed/cc0866735571f4e55d8e0edd09d34aab1ba1a4b83288bafa398651df4d88/ckzg-2.1.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:36e2e198c9e0a94498db32b760b446a1c29ba7e01aaec17404237ef6ae1705df", size = 188907, upload-time = "2026-02-26T17:19:06.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5b/154c5a3ebd6fe97e1bf5de60cb3d3bc4f9ff42565dab87957292d7918eb8/ckzg-2.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5ce6aaac6ad4d70cc6e8ef61b430957150e1eb3370fd898cebd074db85cde987", size = 183602, upload-time = "2026-02-26T17:19:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/81/8d/01bc02cfd24bbe641da36e5cbc50549db505b404a096ea501dcc1920f572/ckzg-2.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:e897650e650fd090b97136103963a0bd338ff8582442b6e4b2bd660b0b81ff2e", size = 99810, upload-time = "2026-02-26T17:19:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/4f4449d60daf573ef4f14ab963e73dbd9803774fba40e839368af503b7de/ckzg-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b10f2b50369d95c2d3707293f958a73cc4a505f53d1dfeadb9534aad4dd33ec9", size = 96402, upload-time = "2026-02-26T17:19:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/24/91/85eb888653ad9c8872b017ae765ec331eb7bac6c49b5815d8f8b687b7928/ckzg-2.1.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:c1642c7c1fd9225155660ee5bf96117b1d94a639a7f495c3b655ad7640bbb5c1", size = 180495, upload-time = "2026-02-26T17:19:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/e42dd754e825ca0aac733993d6c60d202a6c7e4608e0ef75467bba6c1fb8/ckzg-2.1.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb3d119e5008385ec3d47e81965bf1c644f50077fa9aa890d49ee1a0963fbfb3", size = 166328, upload-time = "2026-02-26T17:19:13.512Z" }, + { url = "https://files.pythonhosted.org/packages/7d/35/6d94c0cecf02bec72a5b5e3f61e7987a428abb3af714cda25ebb1f2a3681/ckzg-2.1.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e81244ae23f27a6f85dc69838adcd3c5618acef57aec7ed87db8070cd6995bf", size = 176069, upload-time = "2026-02-26T17:19:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/9e6eb717dc9477374e28e5c5b56f210a708bbaa6b9660f09302138776488/ckzg-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92b60f5f9eb880c595680af52d609e06dedee2bcdd109597ce58bb5422639b1a", size = 173743, upload-time = "2026-02-26T17:19:17.172Z" }, + { url = "https://files.pythonhosted.org/packages/1c/42/34cb744193163d33c348ce12f0155296bde1cbe733a139bef102c0ff7fec/ckzg-2.1.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1f9c9b2fd7d5f303eb2420130c1a1ee44a071308e227a8f9e238aeb4e2194ae", size = 188921, upload-time = "2026-02-26T17:19:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/69a2c0e3d17e3e6d1ae40a7b8a75c354ffeb4b604e716daf25c4a743fb18/ckzg-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eaf30b4719199f1d243bd761caaec3582bdad70a6797475c6cd5c03c5ce3cd1d", size = 183603, upload-time = "2026-02-26T17:19:19.579Z" }, + { url = "https://files.pythonhosted.org/packages/3a/21/ea282898caa22622aab9ccd0212f4a5fd9254a949323a406a5c38aee1406/ckzg-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:30964b9fac452746db7e60c9c324957c8dc7bc815b72bb09eea88409decc33ed", size = 102520, upload-time = "2026-02-26T17:19:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/43/4c/ef4177450ccb31c8ff49ffd154e9266390b2f632caced121ec51f9172e4d/ckzg-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4fd1c8e20c52ce77f9ad7b004440b0ba46d22328af07a5eb095ea4f252d22644", size = 96611, upload-time = "2026-02-26T17:19:21.81Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ad/6e684af6b29744012befcb88db688234abc172d261ed4f5819df49ff55a4/ckzg-2.1.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:502bb5e5bbbf1bc14b324d8e012c06fc30c24840d35a7933b80b839869280491", size = 183330, upload-time = "2026-02-26T17:19:22.749Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8e/469ab3b856215a7542792c2bae10dbf5e8e051fef2c50545070977acc5db/ckzg-2.1.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c859c8b93e82b9839a5bb443511a0b0631e93cb9275e755f54781693a3afc246", size = 169465, upload-time = "2026-02-26T17:19:23.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/41/3a5b27f0d8204dd3ed375c3348d462feedc24ef9db9df576e53cb53191b7/ckzg-2.1.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0330b7a7e0aca5622a31089c1d56a1a7040a52075803d31983fa9101fc45dddc", size = 178846, upload-time = "2026-02-26T17:19:25.452Z" }, + { url = "https://files.pythonhosted.org/packages/52/bc/4f15d4642b7c83bdc7c7868f6e809e56ebafc02c1ed43ae541f686185d47/ckzg-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:646078c085edc4c92361f6277cb8b6aac978287306e664e3c29de2f26ad206d2", size = 176486, upload-time = "2026-02-26T17:19:26.876Z" }, + { url = "https://files.pythonhosted.org/packages/51/8b/f046442413da4bd294d3ec6de04adb54af47b1e149f85c127955e10a78cd/ckzg-2.1.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1224f2477fc794f7719bbe7650f735188120351b9511a7dd928b2fe8d74911c3", size = 191686, upload-time = "2026-02-26T17:19:27.889Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/46d383414040cc3f4453c047b2268ef1548e846e5be732fdaf1b20dd5a79/ckzg-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b33131a9674d9dd509eb9fbb59f65c66dc14bfe85bc3dc93af5140274741c12", size = 186202, upload-time = "2026-02-26T17:19:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/bb/43/4d68277e83da239df32096209b0d27626c2d829bae8d9c757abc1687fc13/ckzg-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:73301ca29c29255960ebcee8bf52151cd3ac8de214c31a4e29dbcde8c44e0571", size = 102667, upload-time = "2026-02-26T17:19:30.111Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -404,6 +879,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "courlan" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "tld" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/54/6d6ceeff4bed42e7a10d6064d35ee43a810e7b3e8beb4abeae8cff4713ae/courlan-1.3.2.tar.gz", hash = "sha256:0b66f4db3a9c39a6e22dd247c72cfaa57d68ea660e94bb2c84ec7db8712af190", size = 206382, upload-time = "2024-10-29T16:40:20.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, +] + [[package]] name = "coverage" version = "7.13.1" @@ -521,6 +1010,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "cyclopts" version = "4.4.0" @@ -536,6 +1046,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/18/5ca04dfda3e53b5d07b072033cc9f7bf10f93f78019366bff411433690d1/cyclopts-4.4.0-py3-none-any.whl", hash = "sha256:78ff95a5e52e738a1d0f01e5a3af48049c47748fa2c255f2629a4cef54dcf2b3", size = 195801, upload-time = "2025-12-16T14:03:07.916Z" }, ] +[[package]] +name = "cytoolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/d4/16916f3dc20a3f5455b63c35dcb260b3716f59ce27a93586804e70e431d5/cytoolz-1.1.0.tar.gz", hash = "sha256:13a7bf254c3c0d28b12e2290b82aed0f0977a4c2a2bf84854fcdc7796a29f3b0", size = 642510, upload-time = "2025-10-19T00:44:56.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/4a/b3ddb3ee44fe0045e95dd973746f93f033b6f92cce1fc3cbbe24b329943c/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:76c9b58555300be6dde87a41faf1f97966d79b9a678b7a526fcff75d28ef4945", size = 976728, upload-time = "2025-10-19T00:41:26.5Z" }, + { url = "https://files.pythonhosted.org/packages/42/21/a3681434aa425875dd828bb515924b0f12c37a55c7d2bc5c0c5de3aeb0b4/cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1d638b10d3144795655e9395566ce35807df09219fd7cacd9e6acbdef67946a", size = 986057, upload-time = "2025-10-19T00:41:28.911Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cb/efc1b29e211e0670a6953222afaac84dcbba5cb940b130c0e49858978040/cytoolz-1.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:26801c1a165e84786a99e03c9c9973356caaca002d66727b761fb1042878ef06", size = 992632, upload-time = "2025-10-19T00:41:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/e50621d21e939338c97faab651f58ea7fa32101226a91de79ecfb89d71e1/cytoolz-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a9a464542912d3272f6dccc5142df057c71c6a5cbd30439389a732df401afb7", size = 1317534, upload-time = "2025-10-19T00:41:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6b/25aa9739b0235a5bc4c1ea293186bc6822a4c6607acfe1422423287e7400/cytoolz-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6104fa942aa5784bf54f339563de637557e3443b105760bc4de8f16a7fc79b", size = 992336, upload-time = "2025-10-19T00:41:34.073Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/5f4deb0ff958805309d135d899c764364c1e8a632ce4994bd7c45fb98df2/cytoolz-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56161f0ab60dc4159ec343509abaf809dc88e85c7e420e354442c62e3e7cbb77", size = 986118, upload-time = "2025-10-19T00:41:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e3/f6255b76c8cc0debbe1c0779130777dc0434da6d9b28a90d9f76f8cb67cd/cytoolz-1.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:832bd36cc9123535f1945acf6921f8a2a15acc19cfe4065b1c9b985a28671886", size = 2679563, upload-time = "2025-10-19T00:41:37.926Z" }, + { url = "https://files.pythonhosted.org/packages/59/8a/acc6e39a84e930522b965586ad3a36694f9bf247b23188ee0eb47b1c9ed1/cytoolz-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1842636b6e034f229bf084c2bcdcfd36c8437e752eefd2c74ce9e2f10415cb6e", size = 2813020, upload-time = "2025-10-19T00:41:39.935Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/0083608286ad1716eda7c41f868e85ac549f6fd6b7646993109fa0bdfd98/cytoolz-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:823df012ab90d2f2a0f92fea453528539bf71ac1879e518524cd0c86aa6df7b9", size = 2669312, upload-time = "2025-10-19T00:41:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/d16080b575520fe5da00cede1ece4e0a4180ec23f88dcdc6a2f5a90a7f7f/cytoolz-1.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f1fcf9e7e7b3487883ff3f815abc35b89dcc45c4cf81c72b7ee457aa72d197b", size = 2922147, upload-time = "2025-10-19T00:41:43.252Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bc/716c9c1243701e58cad511eb3937fd550e645293c5ed1907639c5d66f194/cytoolz-1.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4cdb3fa1772116827f263f25b0cdd44c663b6701346a56411960534a06c082de", size = 2981602, upload-time = "2025-10-19T00:41:45.354Z" }, + { url = "https://files.pythonhosted.org/packages/14/bc/571b232996846b27f4ac0c957dc8bf60261e9b4d0d01c8d955e82329544e/cytoolz-1.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1b5c95041741b81430454db65183e133976f45ac3c03454cfa8147952568529", size = 2830103, upload-time = "2025-10-19T00:41:47.959Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/c594afb46ecd78e4b7e1fb92c947ed041807875661ceda73baaf61baba4f/cytoolz-1.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2079fd9f1a65f4c61e6278c8a6d4f85edf30c606df8d5b32f1add88cbbe2286", size = 2533802, upload-time = "2025-10-19T00:41:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/93/83/1edcf95832555a78fc43b975f3ebe8ceadcc9664dd47fd33747a14df5069/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a92a320d72bef1c7e2d4c6d875125cf57fc38be45feb3fac1bfa64ea401f54a4", size = 2706071, upload-time = "2025-10-19T00:41:51.386Z" }, + { url = "https://files.pythonhosted.org/packages/e2/df/035a408df87f25cfe3611557818b250126cd2281b2104cd88395de205583/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06d1c79aa51e6a92a90b0e456ebce2288f03dd6a76c7f582bfaa3eda7692e8a5", size = 2707575, upload-time = "2025-10-19T00:41:53.305Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a4/ef78e13e16e93bf695a9331321d75fbc834a088d941f1c19e6b63314e257/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e1d7be25f6971e986a52b6d3a0da28e1941850985417c35528f6823aef2cfec5", size = 2660486, upload-time = "2025-10-19T00:41:55.542Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/2c3d60682b26058d435416c4e90d4a94db854de5be944dfd069ed1be648a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:964b248edc31efc50a65e9eaa0c845718503823439d2fa5f8d2c7e974c2b5409", size = 2819605, upload-time = "2025-10-19T00:41:58.257Z" }, + { url = "https://files.pythonhosted.org/packages/45/92/19b722a1d83cc443fbc0c16e0dc376f8a451437890d3d9ee370358cf0709/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c9ff2b3c57c79b65cb5be14a18c6fd4a06d5036fb3f33e973a9f70e9ac13ca28", size = 2533559, upload-time = "2025-10-19T00:42:00.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/fa3b7891da51115204416f14192081d3dea0eaee091f123fdc1347de8dd1/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:22290b73086af600042d99f5ce52a43d4ad9872c382610413176e19fc1d4fd2d", size = 2839171, upload-time = "2025-10-19T00:42:01.881Z" }, + { url = "https://files.pythonhosted.org/packages/46/40/d3519d5cd86eebebf1e8b7174ec32dfb6ecec67b48b0cfb92bf226659b5a/cytoolz-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ade74fccd080ea793382968913ee38d7a35c921df435bbf0a6aeecf0d17574", size = 2743379, upload-time = "2025-10-19T00:42:03.809Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/a9e7511f0a13fdbefa5bf73cf8e4763878140de9453fd3e50d6ac57b6be7/cytoolz-1.1.0-cp313-cp313-win32.whl", hash = "sha256:db5dbcfda1c00e937426cbf9bdc63c24ebbc358c3263bfcbc1ab4a88dc52aa8e", size = 900844, upload-time = "2025-10-19T00:42:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/fb7eb403c6a4c81e5a30363f34a71adcc8bf5292dc8ea32e2440aa5668f2/cytoolz-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e2d3fe3b45c3eb7233746f7aca37789be3dceec3e07dcc406d3e045ea0f7bdc", size = 946461, upload-time = "2025-10-19T00:42:07.983Z" }, + { url = "https://files.pythonhosted.org/packages/93/bb/1c8c33d353548d240bc6e8677ee8c3560ce5fa2f084e928facf7c35a6dcf/cytoolz-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:32c559f95ff44a9ebcbd934acaa1e6dc8f3e6ffce4762a79a88528064873d6d5", size = 902673, upload-time = "2025-10-19T00:42:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/4a53acc60f59030fcaf48c7766e3c4c81bd997379425aa45b129396557b5/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9e2cd93b28f667c5870a070ab2b8bb4397470a85c4b204f2454b0ad001cd1ca3", size = 1372336, upload-time = "2025-10-19T00:42:12.104Z" }, + { url = "https://files.pythonhosted.org/packages/ac/90/f28fd8ad8319d8f5c8da69a2c29b8cf52a6d2c0161602d92b366d58926ab/cytoolz-1.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f494124e141a9361f31d79875fe7ea459a3be2b9dadd90480427c0c52a0943d4", size = 1011930, upload-time = "2025-10-19T00:42:14.231Z" }, + { url = "https://files.pythonhosted.org/packages/c9/95/4561c4e0ad1c944f7673d6d916405d68080f10552cfc5d69a1cf2475a9a1/cytoolz-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53a3262bf221f19437ed544bf8c0e1980c81ac8e2a53d87a9bc075dba943d36f", size = 1020610, upload-time = "2025-10-19T00:42:15.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/14/b2e1ffa4995ec36e1372e243411ff36325e4e6d7ffa34eb4098f5357d176/cytoolz-1.1.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47663e57d3f3f124921f38055e86a1022d0844c444ede2e8f090d3bbf80deb65", size = 2917327, upload-time = "2025-10-19T00:42:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/4a/29/7cab6c609b4514ac84cca2f7dca6c509977a8fc16d27c3a50e97f105fa6a/cytoolz-1.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5a8755c4104ee4e3d5ba434c543b5f85fdee6a1f1df33d93f518294da793a60", size = 3108951, upload-time = "2025-10-19T00:42:19.363Z" }, + { url = "https://files.pythonhosted.org/packages/9a/71/1d1103b819458679277206ad07d78ca6b31c4bb88d6463fd193e19bfb270/cytoolz-1.1.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d96ff3d381423af1b105295f97de86d1db51732c9566eb37378bab6670c5010", size = 2807149, upload-time = "2025-10-19T00:42:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d4/3d83a05a21e7d2ed2b9e6daf489999c29934b005de9190272b8a2e3735d0/cytoolz-1.1.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0ec96b3d537cdf47d4e76ded199f7440715f4c71029b45445cff92c1248808c2", size = 3111608, upload-time = "2025-10-19T00:42:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/51/88/96f68354c3d4af68de41f0db4fe41a23b96a50a4a416636cea325490cfeb/cytoolz-1.1.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:208e2f2ef90a32b0acbff3303d90d89b13570a228d491d2e622a7883a3c68148", size = 3179373, upload-time = "2025-10-19T00:42:24.395Z" }, + { url = "https://files.pythonhosted.org/packages/ce/50/ed87a5cd8e6f27ffbb64c39e9730e18ec66c37631db2888ae711909f10c9/cytoolz-1.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d416a81bb0bd517558668e49d30a7475b5445f9bbafaab7dcf066f1e9adba36", size = 3003120, upload-time = "2025-10-19T00:42:26.18Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a7/acde155b050d6eaa8e9c7845c98fc5fb28501568e78e83ebbf44f8855274/cytoolz-1.1.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f32e94c91ffe49af04835ee713ebd8e005c85ebe83e7e1fdcc00f27164c2d636", size = 2703225, upload-time = "2025-10-19T00:42:27.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/9d518597c5bdea626b61101e8d2ff94124787a42259dafd9f5fc396f346a/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15d0c6405efc040499c46df44056a5c382f551a7624a41cf3e4c84a96b988a15", size = 2956033, upload-time = "2025-10-19T00:42:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/93e5f860926165538c85e1c5e1670ad3424f158df810f8ccd269da652138/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bf069c5381d757debae891401b88b3a346ba3a28ca45ba9251103b282463fad8", size = 2862950, upload-time = "2025-10-19T00:42:31.803Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/99d6af00487bedc27597b54c9fcbfd5c833a69c6b7a9b9f0fff777bfc7aa/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d5cf15892e63411ec1bd67deff0e84317d974e6ab2cdfefdd4a7cea2989df66", size = 2861757, upload-time = "2025-10-19T00:42:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/71/ca/adfa1fb7949478135a37755cb8e88c20cd6b75c22a05f1128f05f3ab2c60/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3872c21170f8341656f8692f8939e8800dcee6549ad2474d4c817bdefd62cd", size = 2979049, upload-time = "2025-10-19T00:42:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/70/4c/7bf47a03a4497d500bc73d4204e2d907771a017fa4457741b2a1d7c09319/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b9ddeff8e8fd65eb1fcefa61018100b2b627e759ea6ad275d2e2a93ffac147bf", size = 2699492, upload-time = "2025-10-19T00:42:37.133Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e7/3d034b0e4817314f07aa465d5864e9b8df9d25cb260a53dd84583e491558/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:02feeeda93e1fa3b33414eb57c2b0aefd1db8f558dd33fdfcce664a0f86056e4", size = 2995646, upload-time = "2025-10-19T00:42:38.912Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/be357181c71648d9fe1d1ce91cd42c63457dcf3c158e144416fd51dced83/cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d08154ad45349162b6c37f12d5d1b2e6eef338e657b85e1621e4e6a4a69d64cb", size = 2919481, upload-time = "2025-10-19T00:42:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bf5434fde726c4f80cb99912b2d8e0afa1587557e2a2d7e0315eb942f2de/cytoolz-1.1.0-cp313-cp313t-win32.whl", hash = "sha256:10ae4718a056948d73ca3e1bb9ab1f95f897ec1e362f829b9d37cc29ab566c60", size = 951595, upload-time = "2025-10-19T00:42:42.877Z" }, + { url = "https://files.pythonhosted.org/packages/64/29/39c161e9204a9715321ddea698cbd0abc317e78522c7c642363c20589e71/cytoolz-1.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1bb77bc6197e5cb19784b6a42bb0f8427e81737a630d9d7dda62ed31733f9e6c", size = 1004445, upload-time = "2025-10-19T00:42:44.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/7cbff5e9a689f558cb0bdf277f9562b2ac51acf7cd15e055b8c3efb0e1ef/cytoolz-1.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:563dda652c6ff52d215704fbe6b491879b78d7bbbb3a9524ec8e763483cb459f", size = 926207, upload-time = "2025-10-19T00:42:46.456Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e8/297a85ba700f437c01eba962428e6ab4572f6c3e68e8ff442ce5c9d3a496/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d542cee7c7882d2a914a33dec4d3600416fb336734df979473249d4c53d207a1", size = 980613, upload-time = "2025-10-19T00:42:47.988Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d7/2b02c9d18e9cc263a0e22690f78080809f1eafe72f26b29ccc115d3bf5c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31922849b701b0f24bb62e56eb2488dcd3aa6ae3057694bd6b3b7c4c2bc27c2f", size = 990476, upload-time = "2025-10-19T00:42:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/89/26/b6b159d2929310fca0eff8a4989cd4b1ecbdf7c46fdff46c7a20fcae55c8/cytoolz-1.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e68308d32afd31943314735c1335e4ab5696110e96b405f6bdb8f2a8dc771a16", size = 992712, upload-time = "2025-10-19T00:42:51.306Z" }, + { url = "https://files.pythonhosted.org/packages/42/a0/f7c572aa151ed466b0fce4a327c3cc916d3ef3c82e341be59ea4b9bee9e4/cytoolz-1.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc4bb48b3b866e1867f7c6411a4229e5b44be3989060663713e10efc24c9bd5f", size = 1322596, upload-time = "2025-10-19T00:42:52.978Z" }, + { url = "https://files.pythonhosted.org/packages/72/7c/a55d035e20b77b6725e85c8f1a418b3a4c23967288b8b0c2d1a40f158cbe/cytoolz-1.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:456f77207d1445025d7ef262b8370a05492dcb1490cb428b0f3bf1bd744a89b0", size = 992825, upload-time = "2025-10-19T00:42:55.026Z" }, + { url = "https://files.pythonhosted.org/packages/03/af/39d2d3db322136e12e9336a1f13bab51eab88b386bfb11f91d3faff8ba34/cytoolz-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:174ebc71ebb20a9baeffce6ee07ee2cd913754325c93f99d767380d8317930f7", size = 990525, upload-time = "2025-10-19T00:42:56.666Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bd/65d7a869d307f9b10ad45c2c1cbb40b81a8d0ed1138fa17fd904f5c83298/cytoolz-1.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8b3604fef602bcd53415055a4f68468339192fd17be39e687ae24f476d23d56e", size = 2672409, upload-time = "2025-10-19T00:42:58.81Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fb/74dfd844bfd67e810bd36e8e3903a143035447245828e7fcd7c81351d775/cytoolz-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3604b959a01f64c366e7d10ec7634d5f5cfe10301e27a8f090f6eb3b2a628a18", size = 2808477, upload-time = "2025-10-19T00:43:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/d6/1f/587686c43e31c19241ec317da66438d093523921ea7749bbc65558a30df9/cytoolz-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6db2127a3c1bc2f59f08010d2ae53a760771a9de2f67423ad8d400e9ba4276e8", size = 2636881, upload-time = "2025-10-19T00:43:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/90468cd34f77cb38a11af52c4dc6199efcc97a486395a21bef72e9b7602e/cytoolz-1.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56584745ac647993a016a21bc76399113b7595e312f8d0a1b140c9fcf9b58a27", size = 2937315, upload-time = "2025-10-19T00:43:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/d9/50/7b92cd78c613b92e3509e6291d3fb7e0d72ebda999a8df806a96c40ca9ab/cytoolz-1.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db2c4c3a7f7bd7e03bb1a236a125c8feb86c75802f4ecda6ecfaf946610b2930", size = 2959988, upload-time = "2025-10-19T00:43:05.758Z" }, + { url = "https://files.pythonhosted.org/packages/44/d5/34b5a28a8d9bb329f984b4c2259407ca3f501d1abeb01bacea07937d85d1/cytoolz-1.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48cb8a692111a285d2b9acd16d185428176bfbffa8a7c274308525fccd01dd42", size = 2795116, upload-time = "2025-10-19T00:43:07.411Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d9/5dd829e33273ec03bdc3c812e6c3281987ae2c5c91645582f6c331544a64/cytoolz-1.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2f344ba5eb17dcf38ee37fdde726f69053f54927db8f8a1bed6ac61e5b1890d", size = 2535390, upload-time = "2025-10-19T00:43:09.104Z" }, + { url = "https://files.pythonhosted.org/packages/87/1f/7f9c58068a8eec2183110df051bc6b69dd621143f84473eeb6dc1b32905a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abf76b1c1abd031f098f293b6d90ee08bdaa45f8b5678430e331d991b82684b1", size = 2704834, upload-time = "2025-10-19T00:43:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/d2/90/667def5665333575d01a65fe3ec0ca31b897895f6e3bc1a42d6ea3659369/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ddf9a38a5b686091265ff45b53d142e44a538cd6c2e70610d3bc6be094219032", size = 2658441, upload-time = "2025-10-19T00:43:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/23/79/6615f9a14960bd29ac98b823777b6589357833f65cf1a11b5abc1587c120/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:946786755274f07bb2be0400f28adb31d7d85a7c7001873c0a8e24a503428fb3", size = 2654766, upload-time = "2025-10-19T00:43:14.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/be59c6e0ae02153ef10ae1ff0f380fb19d973c651b50cf829a731f6c9e79/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b8f78b9fed79cf185ad4ddec099abeef45951bdcb416c5835ba05f0a1242c7", size = 2827649, upload-time = "2025-10-19T00:43:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/b7/854ddcf9f9618844108677c20d48f4611b5c636956adea0f0e85e027608f/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fccde6efefdbc02e676ccb352a2ccc8a8e929f59a1c6d3d60bb78e923a49ca44", size = 2533456, upload-time = "2025-10-19T00:43:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/45/66/bfe6fbb2bdcf03c8377c8c2f542576e15f3340c905a09d78a6cb3badd39a/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:717b7775313da5f51b0fbf50d865aa9c39cb241bd4cb605df3cf2246d6567397", size = 2826455, upload-time = "2025-10-19T00:43:19.561Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0c/cce4047bd927e95f59e73319c02c9bc86bd3d76392e0eb9e41a1147a479c/cytoolz-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5158744a09d0e0e4a4f82225e3a3c4ebf38f9ae74467aaa905467270e52f2794", size = 2714897, upload-time = "2025-10-19T00:43:21.291Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9a/061323bb289b565802bad14fb7ab59fcd8713105df142bcf4dd9ff64f8ac/cytoolz-1.1.0-cp314-cp314-win32.whl", hash = "sha256:1ed534bdbbf063b2bb28fca7d0f6723a3e5a72b086e7c7fe6d74ae8c3e4d00e2", size = 901490, upload-time = "2025-10-19T00:43:22.895Z" }, + { url = "https://files.pythonhosted.org/packages/a3/20/1f3a733d710d2a25d6f10b463bef55ada52fe6392a5d233c8d770191f48a/cytoolz-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:472c1c9a085f5ad973ec0ad7f0b9ba0969faea6f96c9e397f6293d386f3a25ec", size = 946730, upload-time = "2025-10-19T00:43:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/2d657db4a5d1c10a152061800f812caba9ef20d7bd2406f51a5fd800c180/cytoolz-1.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7ad7ca3386fa86bd301be3fa36e7f0acb024f412f665937955acfc8eb42deff", size = 905722, upload-time = "2025-10-19T00:43:26.439Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/b4a8c76796a9a8b9bc90c7992840fa1589a1af8e0426562dea4ce9b384a7/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:64b63ed4b71b1ba813300ad0f06b8aff19a12cf51116e0e4f1ed837cea4debcf", size = 1372606, upload-time = "2025-10-19T00:43:28.491Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/a1bb1a32b454a2d650db8374ff3bf875ba0fc1c36e6446ec02a83b9140a1/cytoolz-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60ba6f2ed9eb0003a737e1ee1e9fa2258e749da6477946008d4324efa25149f", size = 1012189, upload-time = "2025-10-19T00:43:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/2f5cbbd81588918ee7dd70cffb66731608f578a9b72166aafa991071af7d/cytoolz-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1aa58e2434d732241f7f051e6f17657e969a89971025e24578b5cbc6f1346485", size = 1020624, upload-time = "2025-10-19T00:43:31.712Z" }, + { url = "https://files.pythonhosted.org/packages/f5/99/c4954dd86cd593cd776a038b36795a259b8b5c12cbab6363edf5f6d9c909/cytoolz-1.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6965af3fc7214645970e312deb9bd35a213a1eaabcfef4f39115e60bf2f76867", size = 2917016, upload-time = "2025-10-19T00:43:33.531Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/f1f70a17e272b433232bc8a27df97e46b202d6cc07e3b0d63f7f41ba0f2d/cytoolz-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddd2863f321d67527d3b67a93000a378ad6f967056f68c06467fe011278a6d0e", size = 3107634, upload-time = "2025-10-19T00:43:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bd/c3226a57474b4aef1f90040510cba30d0decd3515fed48dc229b37c2f898/cytoolz-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4e6b428e9eb5126053c2ae0efa62512ff4b38ed3951f4d0888ca7005d63e56f5", size = 2806221, upload-time = "2025-10-19T00:43:37.707Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/2f7bfe4aaa1e07dc9828bea228ed744faf73b26aee0c1bdf3b5520bf1909/cytoolz-1.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d758e5ef311d2671e0ae8c214c52e44617cf1e58bef8f022b547b9802a5a7f30", size = 3107671, upload-time = "2025-10-19T00:43:39.401Z" }, + { url = "https://files.pythonhosted.org/packages/4d/12/6ff3b04fbd1369d0fcd5f8b5910ba6e427e33bf113754c4c35ec3f747924/cytoolz-1.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a95416eca473e6c1179b48d86adcf528b59c63ce78f4cb9934f2e413afa9b56b", size = 3176350, upload-time = "2025-10-19T00:43:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/6691d986b728e77b5d2872743ebcd962d37a2d0f7e9ad95a81b284fbf905/cytoolz-1.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36c8ede93525cf11e2cc787b7156e5cecd7340193ef800b816a16f1404a8dc6d", size = 3001173, upload-time = "2025-10-19T00:43:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cb/f59d83a5058e1198db5a1f04e4a124c94d60390e4fa89b6d2e38ee8288a0/cytoolz-1.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c949755b6d8a649c5fbc888bc30915926f1b09fe42fea9f289e297c2f6ddd3", size = 2701374, upload-time = "2025-10-19T00:43:44.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1ae6d28df503b0bdae094879da2072b8ba13db5919cd3798918761578411/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1b6d37545816905a76d9ed59fa4e332f929e879f062a39ea0f6f620405cdc27", size = 2953081, upload-time = "2025-10-19T00:43:47.103Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/d86fe811c6222dc32d3e08f5d88d2be598a6055b4d0590e7c1428d55c386/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05332112d4087904842b36954cd1d3fc0e463a2f4a7ef9477bd241427c593c3b", size = 2862228, upload-time = "2025-10-19T00:43:49.353Z" }, + { url = "https://files.pythonhosted.org/packages/ae/32/978ef6f42623be44a0a03ae9de875ab54aa26c7e38c5c4cd505460b0927d/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:31538ca2fad2d688cbd962ccc3f1da847329e2258a52940f10a2ac0719e526be", size = 2861971, upload-time = "2025-10-19T00:43:51.028Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/74c69497e756b752b359925d1feef68b91df024a4124a823740f675dacd3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:747562aa70abf219ea16f07d50ac0157db856d447f7f498f592e097cbc77df0b", size = 2975304, upload-time = "2025-10-19T00:43:52.99Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2b/3ce0e6889a6491f3418ad4d84ae407b8456b02169a5a1f87990dbba7433b/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc15c48b20c0f467e15e341e102896c8422dccf8efc6322def5c1b02f074629", size = 2697371, upload-time = "2025-10-19T00:43:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/c616577f0891d97860643c845f7221e95240aa589586de727e28a5eb6e52/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3c03137ee6103ba92d5d6ad6a510e86fded69cd67050bd8a1843f15283be17ac", size = 2992436, upload-time = "2025-10-19T00:43:57.253Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9f/490c81bffb3428ab1fa114051fbb5ba18aaa2e2fe4da5bf4170ca524e6b3/cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be8e298d88f88bd172b59912240558be3b7a04959375646e7fd4996401452941", size = 2917612, upload-time = "2025-10-19T00:43:59.423Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/0fec2769660ca6472bbf3317ab634675827bb706d193e3240aaf20eab961/cytoolz-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3d407140f5604a89578285d4aac7b18b8eafa055cf776e781aabb89c48738fad", size = 960842, upload-time = "2025-10-19T00:44:01.143Z" }, + { url = "https://files.pythonhosted.org/packages/46/b4/b7ce3d3cd20337becfec978ecfa6d0ef64884d0cf32d44edfed8700914b9/cytoolz-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56e5afb69eb6e1b3ffc34716ee5f92ffbdb5cb003b3a5ca4d4b0fe700e217162", size = 1020835, upload-time = "2025-10-19T00:44:03.246Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1f/0498009aa563a9c5d04f520aadc6e1c0942434d089d0b2f51ea986470f55/cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e", size = 927963, upload-time = "2025-10-19T00:44:04.85Z" }, +] + +[[package]] +name = "dataclass-wizard" +version = "0.39.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ea/6b2811092feecbe9d672e3641196ac5fcbec6664074da5dec8b9fd9b9059/dataclass_wizard-0.39.1.tar.gz", hash = "sha256:1679948ed7c62103f40b34df97d03b35e6b2ad50f58173fdbe30074e2e4730f2", size = 361190, upload-time = "2026-01-06T03:30:57.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/a0/a221942b3fbbafaea4e211744d298366b6a9712c1aa336b05fc1c865ac0c/dataclass_wizard-0.39.1-py3-none-any.whl", hash = "sha256:3324e59eca705882eb34e2b3989b2beadd8c2b523e6269d4002cf1a4a5bf703b", size = 215344, upload-time = "2026-01-06T03:30:54.915Z" }, +] + +[[package]] +name = "dateparser" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/668dfb8c073a5dde3efb80fa382de1502e3b14002fd386a8c1b0b49e92a9/dateparser-1.3.0.tar.gz", hash = "sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5", size = 337152, upload-time = "2026-02-04T16:00:06.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/c7/95349670e193b2891176e1b8e5f43e12b31bff6d9994f70e74ab385047f6/dateparser-1.3.0-py3-none-any.whl", hash = "sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a", size = 318688, upload-time = "2026-02-04T16:00:04.652Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -546,12 +1186,15 @@ wheels = [ ] [[package]] -name = "diskcache" -version = "5.6.3" +name = "deprecated" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] [[package]] @@ -581,6 +1224,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -621,6 +1287,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5a/8af5b96ce5622b6168854f479ce846cf7fb589813dcc7d8724233c37ded3/duckdb-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:90f241f25cffe7241bf9f376754a5845c74775e00e1c5731119dc88cd71e0cb2", size = 13527759, upload-time = "2025-12-09T10:59:05.496Z" }, ] +[[package]] +name = "duckduckgo-search" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/ef/07791a05751e6cc9de1dd49fb12730259ee109b18e6d097e25e6c32d5617/duckduckgo_search-8.1.1.tar.gz", hash = "sha256:9da91c9eb26a17e016ea1da26235d40404b46b0565ea86d75a9f78cc9441f935", size = 22868, upload-time = "2025-07-06T15:30:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/72/c027b3b488b1010cf71670032fcf7e681d44b81829d484bb04e31a949a8d/duckduckgo_search-8.1.1-py3-none-any.whl", hash = "sha256:f48adbb06626ee05918f7e0cef3a45639e9939805c4fc179e68c48a12f1b5062", size = 18932, upload-time = "2025-07-06T15:30:58.339Z" }, +] + +[[package]] +name = "e2b" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/1d/e4a50fbb1ff84f37b8ee1c26f83712f3fc09afa7cad8cec1c7fb616d28b2/e2b-2.15.1.tar.gz", hash = "sha256:a4f1bbc8b5180a8a1098079257fcb73e42503ed546098f676f722f11f0d68c09", size = 139685, upload-time = "2026-03-06T13:34:38.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/54/c3cc4dc6a8e5ae506af176fd1efbf51a5d0f614c1dba12daa87e481f244c/e2b-2.15.1-py3-none-any.whl", hash = "sha256:a3bc4e004eab51fb05bae44e9ee4fe821e4637260f4ce3064c8f7c6ed7f5a2a0", size = 257003, upload-time = "2026-03-06T13:34:37.192Z" }, +] + +[[package]] +name = "e2b-code-interpreter" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "e2b" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/eb/db6e51edd9f3402fd68d026572579b9b1bd833b10d990376a1e4c05d5b8d/e2b_code_interpreter-2.4.1.tar.gz", hash = "sha256:4b15014ee0d0dfcdc3072e1f409cbb87ca48f48d53d75629b7257e5513b9e7dd", size = 10700, upload-time = "2025-11-26T18:12:38.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/e7/09b9106ead227f7be14bd97c3181391ee498bb38933b1a9c566b72c8567a/e2b_code_interpreter-2.4.1-py3-none-any.whl", hash = "sha256:15d35f025b4a15033e119f2e12e7ac65657ad2b5a013fa9149e74581fbee778a", size = 13719, upload-time = "2025-11-26T18:12:36.7Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "elevenlabs" +version = "2.38.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/e2/f160788e20adaa5b8c9d61e17381131ef8b91003c0c84c982442fae32c2b/elevenlabs-2.38.1.tar.gz", hash = "sha256:4dba9e4b09639d1c2fb703792f1d9696cf2e36f4ff8800744839690f1173c0b2", size = 523201, upload-time = "2026-03-06T10:09:15.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/20/9a22e8fafafcf0a25a87065bf078ed73c98934414694950c0920d21a1a48/elevenlabs-2.38.1-py3-none-any.whl", hash = "sha256:a726347a38ab1fbe6d74094f327d0baf6b5eb1cc06bab21318b12a9d9f0d7f24", size = 1412352, upload-time = "2026-03-06T10:09:13.113Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -643,6 +1387,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "eth-abi" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "parsimonious" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/71/d9e1380bd77fd22f98b534699af564f189b56d539cc2b9dab908d4e4c242/eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0", size = 49797, upload-time = "2025-01-14T16:29:34.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/b4/2f3982c4cbcbf5eeb6aec62df1533c0e63c653b3021ff338d44944405676/eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877", size = 28511, upload-time = "2025-01-14T16:29:31.862Z" }, +] + +[[package]] +name = "eth-account" +version = "0.13.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "ckzg" }, + { name = "eth-abi" }, + { name = "eth-keyfile" }, + { name = "eth-keys" }, + { name = "eth-rlp" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "rlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/cf/20f76a29be97339c969fd765f1237154286a565a1d61be98e76bb7af946a/eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46", size = 935998, upload-time = "2025-04-21T21:11:21.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/18/088fb250018cbe665bc2111974301b2d59f294a565aff7564c4df6878da2/eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24", size = 587452, upload-time = "2025-04-21T21:11:18.346Z" }, +] + +[[package]] +name = "eth-hash" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/38/577b7bc9380ef9dff0f1dffefe0c9a1ded2385e7a06c306fd95afb6f9451/eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5", size = 12227, upload-time = "2025-01-13T21:29:21.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/db/f8775490669d28aca24871c67dd56b3e72105cb3bcae9a4ec65dd70859b3/eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a", size = 8028, upload-time = "2025-01-13T21:29:19.365Z" }, +] + +[package.optional-dependencies] +pycryptodome = [ + { name = "pycryptodome" }, +] + +[[package]] +name = "eth-keyfile" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-keys" }, + { name = "eth-utils" }, + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/66/dd823b1537befefbbff602e2ada88f1477c5b40ec3731e3d9bc676c5f716/eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1", size = 12267, upload-time = "2024-04-23T20:28:53.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fc/48a586175f847dd9e05e5b8994d2fe8336098781ec2e9836a2ad94280281/eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64", size = 7510, upload-time = "2024-04-23T20:28:51.063Z" }, +] + +[[package]] +name = "eth-keys" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-typing" }, + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/11/1ed831c50bd74f57829aa06e58bd82a809c37e070ee501c953b9ac1f1552/eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814", size = 30166, upload-time = "2025-04-07T17:40:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/25/0ae00f2b0095e559d61ad3dc32171bd5a29dfd95ab04b4edd641f7c75f72/eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf", size = 20656, upload-time = "2025-04-07T17:40:20.441Z" }, +] + +[[package]] +name = "eth-rlp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "rlp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ea/ad39d001fa9fed07fad66edb00af701e29b48be0ed44a3bcf58cb3adf130/eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d", size = 7720, upload-time = "2025-02-04T21:51:08.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3b/57efe2bc2df0980680d57c01a36516cd3171d2319ceb30e675de19fc2cc5/eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47", size = 4446, upload-time = "2025-02-04T21:51:05.823Z" }, +] + +[[package]] +name = "eth-typing" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/54/62aa24b9cc708f06316167ee71c362779c8ed21fc8234a5cd94a8f53b623/eth_typing-5.2.1.tar.gz", hash = "sha256:7557300dbf02a93c70fa44af352b5c4a58f94e997a0fd6797fb7d1c29d9538ee", size = 21806, upload-time = "2025-04-14T20:39:28.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/72/c370bbe4c53da7bf998d3523f5a0f38867654923a82192df88d0705013d3/eth_typing-5.2.1-py3-none-any.whl", hash = "sha256:b0c2812ff978267563b80e9d701f487dd926f1d376d674f3b535cfe28b665d3d", size = 19163, upload-time = "2025-04-14T20:39:26.571Z" }, +] + +[[package]] +name = "eth-utils" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cytoolz", marker = "implementation_name == 'cpython'" }, + { name = "eth-hash" }, + { name = "eth-typing" }, + { name = "pydantic" }, + { name = "toolz", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/e1/ee3a8728227c3558853e63ff35bd4c449abdf5022a19601369400deacd39/eth_utils-5.3.1.tar.gz", hash = "sha256:c94e2d2abd024a9a42023b4ddc1c645814ff3d6a737b33d5cfd890ebf159c2d1", size = 123506, upload-time = "2025-08-27T16:37:17.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/4d/257cdc01ada430b8e84b9f2385c2553f33218f5b47da9adf0a616308d4b7/eth_utils-5.3.1-py3-none-any.whl", hash = "sha256:1f5476d8f29588d25b8ae4987e1ffdfae6d4c09026e476c4aad13b32dda3ead0", size = 102529, upload-time = "2025-08-27T16:37:15.449Z" }, +] + +[[package]] +name = "exa-py" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/0c/3ffaf0379867c9812c44646fc2d3410fce3692ceab9d70730c24c8116b5c/exa_py-2.7.0.tar.gz", hash = "sha256:d2df74c83d9ee45eaa3677a53aace7335df9f0778720c571033a7edcfcb016d5", size = 49580, upload-time = "2026-03-04T01:01:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/e2/663215c8b39df8b867b3a063fe333289873b4946c2136d6a1679a438aa51/exa_py-2.7.0-py3-none-any.whl", hash = "sha256:5780a34b4bcf8738ddc3226c0427f02e2f68753c95d932f0d5f5f5604447e92a", size = 64486, upload-time = "2026-03-04T01:01:40.911Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -653,21 +1533,18 @@ wheels = [ ] [[package]] -name = "fakeredis" -version = "2.33.0" +name = "fal-client" +version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "msgpack" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/2c/3097270895a959aa4304b8e38c598182973ab106166e4ae3810533270bd3/fal_client-0.13.1.tar.gz", hash = "sha256:9e1c07d0a61b452a8ffb48c199de5f2543d7546f1230f6312370443127c5e937", size = 30281, upload-time = "2026-02-20T07:21:29.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, + { url = "https://files.pythonhosted.org/packages/6a/48/265c2935467ac1dbcb7c5b54cd8a2f579cbb263db6bfc0e0c8fe4bc79c02/fal_client-0.13.1-py3-none-any.whl", hash = "sha256:967a01f3a4112d485a30f8f3a0e678c6ff5b919eb9c5d480315cfc30a79fc037", size = 19265, upload-time = "2026-02-20T07:21:28.143Z" }, ] [[package]] @@ -687,29 +1564,45 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.14.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331, upload-time = "2025-12-15T02:26:27.102Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" }, +] + +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, ] [[package]] @@ -721,6 +1614,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, ] +[[package]] +name = "firecrawl-py" +version = "4.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "httpx" }, + { name = "nest-asyncio" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e0/32ee1a80d48c46de4beaa88d799a624c3dfc691f6cb0a7ecd2037885d328/firecrawl_py-4.18.1.tar.gz", hash = "sha256:4ffa2034b304ffc50d8e2af5993b7ae40fd39f0f94cedc22b6e06c19470631be", size = 169907, upload-time = "2026-03-07T02:54:06.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f1/c1fb9eb9115020f6521f45e62cf7ff065e8971a0222c6f6af48ec7b0478a/firecrawl_py-4.18.1-py3-none-any.whl", hash = "sha256:d374c21b41149845bd614f51db2f650b97c6f2805b63277714070abc7abc27fa", size = 213017, upload-time = "2026-03-07T02:54:05.566Z" }, +] + [[package]] name = "flatbuffers" version = "25.12.19" @@ -729,6 +1640,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "google-api-core" version = "2.28.1" @@ -775,6 +1768,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, ] +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + [[package]] name = "google-auth-httplib2" version = "0.3.0" @@ -801,6 +1799,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/07/a54c100da461ffc5968457823fcc665a48fb4b875c68bcfecbfe24a10dbe/google_auth_oauthlib-1.2.3-py3-none-any.whl", hash = "sha256:7c0940e037677f25e71999607493640d071212e7f3c15aa0febea4c47a5a0680", size = 19184, upload-time = "2025-10-30T21:28:17.88Z" }, ] +[[package]] +name = "google-genai" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/7c/19b59750592702305ae211905985ec8ab56f34270af4a159fba5f0214846/google_genai-1.55.0.tar.gz", hash = "sha256:ae9f1318fedb05c7c1b671a4148724751201e8908a87568364a309804064d986", size = 477615, upload-time = "2025-12-11T02:49:28.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/86/a5a8e32b2d40b30b5fb20e7b8113fafd1e38befa4d1801abd5ce6991065a/google_genai-1.55.0-py3-none-any.whl", hash = "sha256:98c422762b5ff6e16b8d9a1e4938e8e0ad910392a5422e47f5301498d7f373a1", size = 703389, upload-time = "2025-12-11T02:49:27.105Z" }, +] + +[[package]] +name = "google-search-results" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/30/b3a6f6a2e00f8153549c2fa345c58ae1ce8e5f3153c2fe0484d444c3abcb/google_search_results-2.4.2.tar.gz", hash = "sha256:603a30ecae2af8e600b22635757a6df275dad4b934f975e67878ccd640b78245", size = 18818, upload-time = "2023-03-10T11:13:09.953Z" } + [[package]] name = "googleapis-common-protos" version = "1.72.0" @@ -833,6 +1861,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/65/5b735d5713e0e1bb7f3a5c85d1e7e2088b8108d3a8de8850485e071dbe84/grafi-0.0.34-py3-none-any.whl", hash = "sha256:3bbd1537b8dee52db8b485b9e51b08577462b9844cc4d9d043e895f8ce32982d", size = 123984, upload-time = "2025-12-31T18:13:00.37Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + [[package]] name = "grpcio" version = "1.76.0" @@ -873,6 +1935,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hexbytes" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/87/adf4635b4b8c050283d74e6db9a81496063229c9263e6acc1903ab79fbec/hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765", size = 8633, upload-time = "2025-05-14T16:45:17.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e0/3b31492b1c89da3c5a846680517871455b30c54738486fc57ac79a5761bd/hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7", size = 5074, upload-time = "2025-05-14T16:45:16.179Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "htmldate" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "dateparser" }, + { name = "lxml" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/10/ead9dabc999f353c3aa5d0dc0835b1e355215a5ecb489a7f4ef2ddad5e33/htmldate-1.9.4.tar.gz", hash = "sha256:1129063e02dd0354b74264de71e950c0c3fcee191178321418ccad2074cc8ed0", size = 44690, upload-time = "2025-11-04T17:46:44.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/bd/adfcdaaad5805c0c5156aeefd64c1e868c05e9c1cd6fd21751f168cd88c7/htmldate-1.9.4-py3-none-any.whl", hash = "sha256:1b94bcc4e08232a5b692159903acf95548b6a7492dddca5bb123d89d6325921c", size = 31558, upload-time = "2025-11-04T17:46:43.258Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -913,6 +2022,11 @@ 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.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -939,18 +2053,66 @@ name = "humcp" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "anthropic" }, + { name = "apify-client" }, + { name = "arxiv" }, + { name = "asyncpg" }, + { name = "atlassian-python-api" }, + { name = "authlib" }, + { name = "baidusearch" }, { name = "black" }, + { name = "boto3" }, + { name = "cartesia" }, + { name = "docker" }, { name = "duckdb" }, + { name = "duckduckgo-search" }, + { name = "e2b-code-interpreter" }, + { name = "elevenlabs" }, + { name = "exa-py" }, + { name = "fal-client" }, { name = "fastapi" }, { name = "fastmcp" }, + { name = "firecrawl-py" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, + { name = "google-genai" }, + { name = "google-search-results" }, + { name = "httpx" }, + { name = "jira" }, + { name = "linkup-sdk" }, + { name = "lumaai" }, { name = "markitdown", extra = ["all"] }, + { name = "mem0ai" }, + { name = "minio" }, + { name = "moviepy" }, + { name = "neo4j" }, + { name = "newspaper4k" }, + { name = "ollama" }, + { name = "openai" }, { name = "pandas" }, + { name = "pillow" }, + { name = "praw" }, { name = "pydantic" }, + { name = "pypdf" }, + { name = "pytesseract" }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "replicate" }, + { name = "resend" }, + { name = "restrictedpython" }, + { name = "slack-sdk" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "tavily-python" }, + { name = "todoist-api-python" }, + { name = "trafilatura" }, + { name = "twilio" }, { name = "uvicorn" }, + { name = "web3" }, + { name = "wikipedia" }, + { name = "yfinance" }, + { name = "youtube-transcript-api" }, + { name = "zep-cloud" }, ] [package.dev-dependencies] @@ -967,18 +2129,66 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic" }, + { name = "apify-client" }, + { name = "arxiv" }, + { name = "asyncpg" }, + { name = "atlassian-python-api" }, + { name = "authlib", specifier = ">=1.3.0" }, + { name = "baidusearch" }, { name = "black", specifier = ">=25.12.0" }, + { name = "boto3" }, + { name = "cartesia" }, + { name = "docker" }, { name = "duckdb", specifier = ">=1.4.2" }, + { name = "duckduckgo-search" }, + { name = "e2b-code-interpreter" }, + { name = "elevenlabs" }, + { name = "exa-py" }, + { name = "fal-client" }, { name = "fastapi", specifier = ">=0.104.0" }, - { name = "fastmcp", specifier = ">=2.13.0.2" }, + { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "firecrawl-py" }, { name = "google-api-python-client", specifier = ">=2.168.0" }, { name = "google-auth-httplib2", specifier = ">=0.2.0" }, { name = "google-auth-oauthlib", specifier = ">=1.2.2" }, + { name = "google-genai" }, + { name = "google-search-results" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jira" }, + { name = "linkup-sdk" }, + { name = "lumaai" }, { name = "markitdown", extras = ["all"], specifier = ">=0.1.4" }, + { name = "mem0ai" }, + { name = "minio", specifier = ">=7.0.0" }, + { name = "moviepy" }, + { name = "neo4j" }, + { name = "newspaper4k" }, + { name = "ollama" }, + { name = "openai" }, { name = "pandas", specifier = ">=2.3.3" }, + { name = "pillow" }, + { name = "praw" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pypdf" }, + { name = "pytesseract" }, + { name = "python-dotenv" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "replicate" }, + { name = "resend" }, + { name = "restrictedpython" }, + { name = "slack-sdk", specifier = ">=3.33.0" }, + { name = "sqlalchemy", extras = ["asyncio"] }, { name = "tavily-python", specifier = ">=0.7.13" }, + { name = "todoist-api-python" }, + { name = "trafilatura" }, + { name = "twilio" }, { name = "uvicorn", specifier = ">=0.24.0" }, + { name = "web3" }, + { name = "wikipedia" }, + { name = "yfinance" }, + { name = "youtube-transcript-api" }, + { name = "zep-cloud" }, ] [package.metadata.requires-dev] @@ -993,22 +2203,92 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, ] [[package]] -name = "idna" -version = "3.11" +name = "impit" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/25/e3/a765812d447714a9606e388325b59602ae61a7da6e59cd981a5dd2eedb11/impit-0.12.0.tar.gz", hash = "sha256:c9a29ba3cee820d2a0f11596a056e8316497b2e7e2ec789db180d72d35d344ac", size = 148594, upload-time = "2026-03-06T13:39:47.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b1/a7cb954b72306055f5672ad635227d8b8b495dab14a6ca289c8c71430e96/impit-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d75b2a17fea6e4d02af08da7dd72852f23c70e167c168c43c3fb1f8b307be0d9", size = 3799190, upload-time = "2026-03-06T13:38:54.691Z" }, + { url = "https://files.pythonhosted.org/packages/24/e7/6152812b98896aa792086100d9f40b64570fcb5e2441a0222ae110ff6d19/impit-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e39731ec656857f5c445b7035e32f7ae99f126b9934bc08e55e837143192bfd", size = 3666041, upload-time = "2026-03-06T13:38:56.826Z" }, + { url = "https://files.pythonhosted.org/packages/de/a8/1dfdc748c980ca4604f99e06e0e430e237806056c761fc9f19ea3e70e228/impit-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:950837440cebba6466fc319ce7131aa720954b603f805b919a9a9837ce8e3834", size = 4004426, upload-time = "2026-03-06T13:38:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/52/cd/103a0f466a0ff957c7e24de2e38bd9c23b1bf4c39c269f2f014b1c15f304/impit-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:cb00b49b85def8a94f1717f1f91ea0d96b39b98b1c5e5343ea43ecd5087f9c08", size = 3872242, upload-time = "2026-03-06T13:39:00.687Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/de44068629e7807c4aaf939c87c04fe5e97e3b2f581cdbe68c362b779897/impit-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a6fc27136dbac34495d7f947c244b32db25a49d9c175e557b8d1838eec64a68", size = 4083853, upload-time = "2026-03-06T13:39:02.431Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/0e699ce9064648541e3676ef3287745cfce6d14b6aaaccf4a1e86dd69a80/impit-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:818d95b4958c451e230f8215b2ab920d521999bb53bb84438cf8b0b8efa37c7e", size = 4232069, upload-time = "2026-03-06T13:39:05.294Z" }, + { url = "https://files.pythonhosted.org/packages/64/59/2869356464ac123c32b5fa53d912b2acc3156e932475dd02e64779099c83/impit-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e1cbdce736ea66b2da3fe82a2c5961fe1fce35d98bcfb3130600dc78824b1fda", size = 3678217, upload-time = "2026-03-06T13:39:06.983Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/df495e9e1e23b6ec6b5a0a23b0b2b38a6666044bdfdc9b7b34d657dd8d06/impit-0.12.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d508c287eae4645cde6f506ffa7e103706676dd72b85fe42940f6eb2159711bb", size = 3799269, upload-time = "2026-03-06T13:39:08.74Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a0/dd79cd8b8315b4ddfd81ffd98c44728e40bdc0ea03e857db02814a262ca4/impit-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0b28289e9506a83ab3d372daec5bf7d7bcad0b386ed2c646cdce312250bc89d6", size = 3665883, upload-time = "2026-03-06T13:39:10.471Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/1b633977728fe79802478fa03144ee5cfb66683889d3ce842afd2846b75a/impit-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41d24979132f13b77573da44ca5894ed36d82ffcc8407959e32087afc1bd395c", size = 4005477, upload-time = "2026-03-06T13:39:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/d9/90/9e3fa3f6ad6754ab7813e75e750201d956084b19ec8aa0df0a257ae1be4e/impit-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:00b29070c410594af878cfcd87e1f039e1b24b6e0989842700c285da65d1f934", size = 3872180, upload-time = "2026-03-06T13:39:14.291Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/2153114da2ec93a493c7e1440d06b542772d728b3286541b655128ec04b7/impit-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b9942b8208c0b0e95eec1f479f60def0c16249fdd346693e68c90b9cb41cc6c8", size = 4083682, upload-time = "2026-03-06T13:39:16.286Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/76d50922e2973d5631e2a7329c32e1cec39be7bd26077e797fd132401b5d/impit-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1dc2702225eadbd501b748e4c435126a6b1ecab0578bb81da0ef364ee642c80b", size = 4232459, upload-time = "2026-03-06T13:39:18.302Z" }, + { url = "https://files.pythonhosted.org/packages/44/32/7b1d30540b92276e7f8ebd281e319bf661c7ba461a611e89fefd3d6a6443/impit-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9d622e3f3bfc62f2cd3a7dbd036d5932f7578f7a329731709a366085f6eb4187", size = 3799126, upload-time = "2026-03-06T13:39:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/2e/16/911eafb12a8d0954c9260559d9e0658b72b2fda02e5a6ef08c8fb418beab/impit-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:547fe4ee89001d3073590df6d4d985a727b09adb8aa2697a2ce97e8ecfdd3125", size = 3666157, upload-time = "2026-03-06T13:39:22.496Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/122e636d8139ddb44c5543d3dd976a74c10a8a4bf9738f74f57eac143d7d/impit-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1b973041bcb1ef7d57ade5c649ba4bd5dd7417cdbba3a3d399d6a1e578672b5", size = 4004895, upload-time = "2026-03-06T13:39:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/4812bd6f8c72152d3b2c718f5dfa06a4389dee7720340aaad42ba72ef3ac/impit-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:120574e7749036683337e0f4ffa924c7dc020e67f00f48515b1e3d2eb07491e0", size = 3872457, upload-time = "2026-03-06T13:39:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/f6bceaab2fa49ef9f740e0bf5ac80c66051e2a08b45f1eeb7b400095c655/impit-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:365e6bd562a55bacb80efbffa5f6a92c927789f7229b25af14e4bdec45e3798a", size = 4084106, upload-time = "2026-03-06T13:39:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d4/e4b5186411703d0f57c9c1c172a859622a226268cb61cee99d7fb7a93258/impit-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36e858d3198c0c3cfcd2833adde300b831184925518909828555a17f61ab99bd", size = 4232106, upload-time = "2026-03-06T13:39:31.985Z" }, + { url = "https://files.pythonhosted.org/packages/37/c8/d7401673a5ab290357503597b6b8d54e90f296f9f53f1ce93b756a82e08a/impit-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:ef5c129d5ae69f1fbab9ede66fd81ff2e8b277ddd47e7cbdb12bc7035e389ad8", size = 3678100, upload-time = "2026-03-06T13:39:34.357Z" }, + { url = "https://files.pythonhosted.org/packages/51/b8/07d27c8a6f22a211f94d21c13cf3eabb09e10ad9d72d7f873bb77c9ed816/impit-0.12.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ad14a03cd2c44b3cbc79ae8cdc0717fe7b797d49fa11315751700f17dacef9f5", size = 3799520, upload-time = "2026-03-06T13:39:36.066Z" }, + { url = "https://files.pythonhosted.org/packages/c0/dc/ae90a5e77645bc059313cb3794d81ca2fc8d3456a6c5071237f414c2eb04/impit-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3a8dee56d4d9c941f3641025692a037fd6a120ceb6a447e5195dd0f149b7bcf9", size = 3665673, upload-time = "2026-03-06T13:39:38.732Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e9/52e8a89f1c06137595ae421f34e3c9558681e13eb1c6418564acb80dda5a/impit-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6df30d4760cb6223db110061c147cb15a9e1e830c25a6a4ce9b0fde5728704ec", size = 4005490, upload-time = "2026-03-06T13:39:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/2c/45/2dfd122e2952aef8266476c2955f56d4aa26f367d9deea1d994faf1d0ee8/impit-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:10d4686d0cdf11028ef9a5cdf842d8eb77863ed4b338d42328c61c83176f95a2", size = 3872188, upload-time = "2026-03-06T13:39:42.092Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/86f42e3d1554615f0a158b69b14cf821a1d806baff731427230b0b5a6996/impit-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:edca130ddcbfa731ebd875b4c7b2b5531083d845c1222ae33d4ef405144846eb", size = 4083719, upload-time = "2026-03-06T13:39:44.092Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7c/7ba4b99307bb084ab0891dccf1689195657a6ac675f7d1a8b0f134973fe2/impit-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:58c26d748480f7a937f6777503b1a88beda8bf548a7275238de8dc34edaa94bc", size = 4232704, upload-time = "2026-03-06T13:39:45.838Z" }, ] [[package]] @@ -1083,6 +2363,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jira" +version = "3.10.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "packaging" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "requests-toolbelt" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/73/ee4daa7cf4eea457180de0ea78b730b44bb5ad2829dae49cf708a1460819/jira-3.10.5.tar.gz", hash = "sha256:2d09ae3bf4741a2787dd889dfea5926a5d509aac3b28ab3b98c098709e6ee72d", size = 105870, upload-time = "2025-07-28T12:18:22.796Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/57/ad078d7379e390798559446607e413fc953c7510711462ab34194dba5924/jira-3.10.5-py3-none-any.whl", hash = "sha256:d4da1385c924ee693d6cc9838e56a34e31d74f0d6899934ef35bbd0d2d33997f", size = 79250, upload-time = "2025-07-28T12:18:21.368Z" }, +] + [[package]] name = "jiter" version = "0.12.0" @@ -1134,6 +2431,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1176,6 +2500,18 @@ 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 = "justext" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1234,6 +2570,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724, upload-time = "2025-12-15T16:52:29.836Z" }, ] +[[package]] +name = "linkup-sdk" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/fa/d54d7086ceb8e8aa3777ae82f9876ceb7d15a6f583bbebf2fc2bea4cf69c/linkup_sdk-0.13.0.tar.gz", hash = "sha256:dab3f516bb955bdb9dd5815445bfdc7a2c9803dc57c3b4be4038d9e40f3d096a", size = 76440, upload-time = "2026-03-02T13:09:25.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b8/9a8be932db54dc673c0a2f033831e9963cb4395352ce5a54a423e2fb58de/linkup_sdk-0.13.0-py3-none-any.whl", hash = "sha256:d4f5f4698cbaf4711a3296473f6030613c9c8ac829c83172a51c34c6e653808a", size = 11750, upload-time = "2026-03-02T13:09:24.553Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1248,44 +2597,20 @@ wheels = [ ] [[package]] -name = "lupa" -version = "2.6" +name = "lumaai" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/06/c02539753c28f49ec64046a43d3f6b77221956cffa33dc601fb30fcb24f7/lumaai-1.20.1.tar.gz", hash = "sha256:2b35e232b2ba09d310c4eb784e498fff4eae3b5b43a233c7af16fd7586fa5f74", size = 121066, upload-time = "2026-03-07T21:15:17.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/ab/43e271939d432c65c2c57bd41bfb45e35f1ffd18ac59933f27db64cfe382/lumaai-1.20.1-py3-none-any.whl", hash = "sha256:a7f5be568898da0e979997c42519fca75247fb8f67c55d7f927013dadac6e028", size = 95332, upload-time = "2026-03-07T21:15:16.29Z" }, ] [[package]] @@ -1350,6 +2675,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, ] +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, +] + [[package]] name = "magika" version = "0.6.3" @@ -1474,6 +2816,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mem0ai" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/79/2307e5fe1610d2ad0d08688af10cd5163861390deeb070f83449c0b65417/mem0ai-1.0.5.tar.gz", hash = "sha256:0835a0001ecac40ba2667bbf17629329c1b2f33eaa585e93a6be54d868a82f79", size = 182982, upload-time = "2026-03-03T22:27:09.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/0e/43ec9f125ebe6e8390805aa56237ee7165fc4f2b796122644cb0043e6631/mem0ai-1.0.5-py3-none-any.whl", hash = "sha256:0526814d2ec9134e21a628cc04ae0e6dc1779a579af92c481cb9fd7f7b8d17aa", size = 275991, upload-time = "2026-03-03T22:27:07.73Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -1483,6 +2859,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "moviepy" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "imageio" }, + { name = "imageio-ffmpeg" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "proglog" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/61/15f9476e270f64c78a834e7459ca045d669f869cec24eed26807b8cd479d/moviepy-2.2.1.tar.gz", hash = "sha256:c80cb56815ece94e5e3e2d361aa40070eeb30a09d23a24c4e684d03e16deacb1", size = 58431438, upload-time = "2025-05-21T19:31:52.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/73/7d3b2010baa0b5eb1e4dfa9e4385e89b6716be76f2fa21a6c0fe34b68e5a/moviepy-2.2.1-py3-none-any.whl", hash = "sha256:6b56803fec2ac54b557404126ac1160e65448e03798fa282bd23e8fab3795060", size = 129871, upload-time = "2025-05-21T19:31:50.11Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1518,6 +2912,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + [[package]] name = "mypy" version = "1.19.1" @@ -1554,6 +3070,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "newspaper4k" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "brotli" }, + { name = "feedparser" }, + { name = "lxml", extra = ["html-clean"] }, + { name = "nltk" }, + { name = "pillow" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tldextract" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/7d/2f8c048a1a04023f156ddc77825900de9e9c98e05b80106484cfa84a40a4/newspaper4k-0.9.4.tar.gz", hash = "sha256:3299f654ee932272cb4fd28ab68733793df092473cefe407d4bcef86dc734858", size = 3970805, upload-time = "2025-11-15T21:53:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/bd/8a71f1b8ec2b64c9c0c44808828c5f918cb110c06d19b42874fd2759d0b6/newspaper4k-0.9.4-py3-none-any.whl", hash = "sha256:10d1f98e9c00d35b32283cfab344790ea3b89d8a923865a19ad7dcf49b16096c", size = 305978, upload-time = "2025-11-15T21:53:28.314Z" }, +] + +[[package]] +name = "nltk" +version = "3.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/8f/915e1c12df07c70ed779d18ab83d065718a926e70d3ea33eb0cd66ffb7c0/nltk-3.9.3.tar.gz", hash = "sha256:cb5945d6424a98d694c2b9a0264519fab4363711065a46aa0ae7a2195b92e71f", size = 2923673, upload-time = "2026-02-24T12:05:53.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl", hash = "sha256:60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522", size = 1525385, upload-time = "2026-02-24T12:05:46.54Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -1631,6 +3205,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, ] +[[package]] +name = "ollama" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, +] + [[package]] name = "onnxruntime" version = "1.20.1" @@ -1811,20 +3398,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, ] -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - [[package]] name = "opentelemetry-instrumentation" version = "0.60b1" @@ -1928,6 +3501,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "parsimonious" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/91/abdc50c4ef06fdf8d047f60ee777ca9b2a7885e1a9cea81343fbecda52d7/parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c", size = 52172, upload-time = "2022-09-03T17:01:17.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, +] + [[package]] name = "pathable" version = "0.4.4" @@ -1946,15 +3531,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - [[package]] name = "pdfminer-six" version = "20251107" @@ -1968,62 +3544,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/29/d1d9f6b900191288b77613ddefb73ed35b48fb35e44aaf8b01b0422b759d/pdfminer_six-20251107-py3-none-any.whl", hash = "sha256:c09df33e4cbe6b26b2a79248a4ffcccafaa5c5d39c9fff0e6e81567f165b5401", size = 5620299, upload-time = "2025-11-07T20:01:08.722Z" }, ] +[[package]] +name = "peewee" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/a0/307e09edfae54f103d66b54d82fdfd22cfc25e733731c97173f4ec661fc3/peewee-4.0.1.tar.gz", hash = "sha256:beb1a173a52952a4d69197ca36408166740b2ced339c0e8159617c3fa1e1c4da", size = 700753, upload-time = "2026-03-01T17:26:56.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/99/0f8d9e46df9812ab31dadb07ba304c3ff6d6076bf19a67f4d046790be2e1/peewee-4.0.1-py3-none-any.whl", hash = "sha256:10afaa02c9f6d0f2dfe0f5b9007075c985eb141ab740a8831e37be2a61b3595c", size = 139369, upload-time = "2026-03-01T17:26:54.597Z" }, +] + [[package]] name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] [[package]] @@ -2044,6 +3626,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "posthog" +version = "7.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/f5/490fbe0cd357bf5efaa026200d2a29aaa5e39cd8272cfe0e2d449f46f2db/posthog-7.9.8.tar.gz", hash = "sha256:52b1fa5f3d3faf2ee2fb7f5eb375332905887f7c1e386ef45103448413bd3e57", size = 176688, upload-time = "2026-03-09T14:34:07.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/aa/8b3de1650e0c39223c7f9b7c0f4961f7d39bfa690fa800a9521565381ecb/posthog-7.9.8-py3-none-any.whl", hash = "sha256:2735bcc3232e22c88034454e820c1739f4b29e606d55f31e56b52202650e4330", size = 202361, upload-time = "2026-03-09T14:34:06.031Z" }, +] + +[[package]] +name = "praw" +version = "7.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prawcore" }, + { name = "update-checker" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/52/7dd0b3d9ccb78e90236420ef6c51b6d9b2400a7229442f0cfcf2258cce21/praw-7.8.1.tar.gz", hash = "sha256:3c5767909f71e48853eb6335fef7b50a43cbe3da728cdfb16d3be92904b0a4d8", size = 154106, upload-time = "2024-10-25T21:49:33.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/ca/60ec131c3b43bff58261167045778b2509b83922ce8f935ac89d871bd3ea/praw-7.8.1-py3-none-any.whl", hash = "sha256:15917a81a06e20ff0aaaf1358481f4588449fa2421233040cb25e5c8202a3e2f", size = 189338, upload-time = "2024-10-25T21:49:31.109Z" }, +] + +[[package]] +name = "prawcore" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/62/d4c99cf472205f1e5da846b058435a6a7c988abf8eb6f7d632a7f32f4a77/prawcore-2.4.0.tar.gz", hash = "sha256:b7b2b5a1d04406e086ab4e79988dc794df16059862f329f4c6a43ed09986c335", size = 15862, upload-time = "2023-10-01T23:30:49.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/5c/8af904314e42d5401afcfaff69940dc448e974f80f7aa39b241a4fbf0cf1/prawcore-2.4.0-py3-none-any.whl", hash = "sha256:29af5da58d85704b439ad3c820873ad541f4535e00bb98c66f0fbcc8c603065a", size = 17203, upload-time = "2023-10-01T23:30:47.651Z" }, +] + [[package]] name = "pre-commit" version = "4.5.1" @@ -2061,12 +3698,122 @@ wheels = [ ] [[package]] -name = "prometheus-client" -version = "0.23.1" +name = "primp" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/35/80be154508529f753fb82cb81298bdeb33e90f39f9901d7cfa0f488a581f/primp-1.1.2.tar.gz", hash = "sha256:c4707ab374a77c0cbead3d9a65605919fa4997fa910ef06e37b65df42a1d4d04", size = 313908, upload-time = "2026-03-01T05:52:49.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/13/dc9588356d983f988877ae065c842cdd6cf95073615b56b460cbe857f3dc/primp-1.1.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:181bb9a6d5544e0483592f693f33f5874a60726ea0da1f41685aa2267f084a4d", size = 4002669, upload-time = "2026-03-01T05:52:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/6a6c26141583a5081bad69b9753c85df81b466939663742ef5bec35ee868/primp-1.1.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f362424ffa83e1de55a7573300a416fa71dc5516829526a9bf77dc0cfa42256b", size = 3743010, upload-time = "2026-03-01T05:52:38.452Z" }, + { url = "https://files.pythonhosted.org/packages/a9/99/03db937e031a02885d8c80d073d7424967d629721b5044dcb4e80b6cbdcf/primp-1.1.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:736820326eb1ed19c6b0e971f852316c049c36bdd251a03757056a74182796df", size = 3889905, upload-time = "2026-03-01T05:52:20.616Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/faecef36238f464e2dd52056420676eb2d541cd20ff478d3b967815079e3/primp-1.1.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed37d1bc89fa8cad8b60481c81ea7b3bd42dc757868009ad3bb0b1e74c17fd22", size = 3524521, upload-time = "2026-03-01T05:52:08.403Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d5/8954e5b5b454139ff35063d5a143a1570f865b736cfd8a46cc7ce9575a5a/primp-1.1.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e78355b1c495bc7e3d92121067760c7e7a1d419519542ed9dd88688ce43aab", size = 3738228, upload-time = "2026-03-01T05:52:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/26/e7/dc93dbeddb7642e12f4575aaf2c9fda7234b241050a112a9baa288971b16/primp-1.1.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c4c560d018dad4e3a3f17b07f9f5d894941e3acbbb5b566f6b6baf42786012f", size = 4013704, upload-time = "2026-03-01T05:52:48.529Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/2cc2e0cd310f585df05a7008fd6de4542d7c0bc61e62b6797f28a9ede28b/primp-1.1.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2494b52cf3056d3e41c0746a11cbeca7f2f882a92a09d87383646cd75e2f3d8c", size = 3920174, upload-time = "2026-03-01T05:52:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/dc4572ba96911374b43b4f5d1f012706c3f27fd2c12dd3e158fcf74ac3dd/primp-1.1.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c424a46f48ccd8fd309215a15bc098b47198b8f779c43ed8d95b3f53a382ffa8", size = 4113822, upload-time = "2026-03-01T05:52:51.061Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2e/90f5f8e138f8bc6652c5134aa59a746775623a820f92164c6690217e49d6/primp-1.1.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba51cf19f17fd4bab4567d96b4cd7dcb6a4e0f0d4721819180b46af9794ae310", size = 4068028, upload-time = "2026-03-01T05:52:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ea/753d8edcb85c3c36d5731fbd2b215528738d917ae9cf3dce651ae0f1c529/primp-1.1.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:77ebae43c6735328051beb08e7e2360b6cf79d50f6cef77629beba880c99222d", size = 3754469, upload-time = "2026-03-01T05:52:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/ae/51/b417cd741bf8eacea86debad358a6dc5821e2849a22e2c91cff926bebbb2/primp-1.1.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5f3252d47e9d0f4a567990c79cd388be43353fc7c78efea2a6a5734e8a425598", size = 3859330, upload-time = "2026-03-01T05:52:46.979Z" }, + { url = "https://files.pythonhosted.org/packages/3e/20/19db933c878748e9a7b9ad4057e9caf7ad9c91fd27d2a2692ac629453a66/primp-1.1.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9e094417825df9748e179a1104b2df4459c3dbd1eea994f05a136860b847f0e1", size = 4365491, upload-time = "2026-03-01T05:52:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/48a57ee744cc6dc64fb7daff7bc04e9ec3cefd0594d008a775496dddaeb1/primp-1.1.2-cp310-abi3-win32.whl", hash = "sha256:bc67112b61a8dc1d40ddcc81ff5c47a1cb7b620954fee01a529e28bebb359e20", size = 3266998, upload-time = "2026-03-01T05:52:02.059Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/119d497fb098c739142d4a47b062a8a9cc0b4b87aca65334150066d075a0/primp-1.1.2-cp310-abi3-win_amd64.whl", hash = "sha256:4509850301c669c04e124762e953946ed10fe9039f059ec40b818c085697d9a4", size = 3601691, upload-time = "2026-03-01T05:52:12.34Z" }, + { url = "https://files.pythonhosted.org/packages/95/1f/2b8f218aebb4f236d94ae148b4f5c0471b3d00316b0ef5d0b7c2222d8417/primp-1.1.2-cp310-abi3-win_arm64.whl", hash = "sha256:de5958dc7ce78ce107dd776056a58f9da7a7164a912e908cb9b66b84f87967f6", size = 3613756, upload-time = "2026-03-01T05:52:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/40/38/f77c5af1fd53658e04ae52decfab71349af43bdfdb32ddd8a622f6251842/primp-1.1.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c3bbece26e8312e3e0df2ec222b954f9ac9f279422ffbbf47a6cad31ef8736cd", size = 3992311, upload-time = "2026-03-01T05:52:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/77/f6/2e4504cfdeec5d39063173205ca10a281a2681fd9999da37b442ac7e6662/primp-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:78acdf43b57d984170e986be5fcae0a1537a245fafda970e92056dae42cd9545", size = 3736438, upload-time = "2026-03-01T05:52:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/fe10c51b79cd407d3a1e08a0bb8a35ae53d79ce4156543ea4df7262581ef/primp-1.1.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89a2641441732f81e1876db2e18490d3210a8302290e4844b7f04159e02033d4", size = 3878622, upload-time = "2026-03-01T05:52:33.458Z" }, + { url = "https://files.pythonhosted.org/packages/fb/86/5c68dc877af9baf4fba3e5d2615fe0aefbdd4e1337d3b678b66769b434c9/primp-1.1.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1df66deacb539efbca5730d0fc3dea19cd83c33422fa05445bbddc17aef3f71", size = 3520112, upload-time = "2026-03-01T05:52:45.214Z" }, + { url = "https://files.pythonhosted.org/packages/fd/aa/f8798a1c0fabbc9254e29330df61b93bdb54130e9d5e5d8495eff99fc658/primp-1.1.2-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78ea1f56dd3ac52f2d5375a084c7f31ce6ad274811bdb5d17ecaca6b4ddb8b6d", size = 3740187, upload-time = "2026-03-01T05:52:26.052Z" }, + { url = "https://files.pythonhosted.org/packages/90/e4/ea08359b6fbcda7b3ffcc15b4c1e0bf4f89680db126ba96889e7f8e1fe04/primp-1.1.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c980527bd46c034ab9e06dca75b6237cea8d5b3fe1f5691904a2c35d92d143c", size = 4011825, upload-time = "2026-03-01T05:52:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/8cf516250cc97eab2d4c822478ab0037b9848bca844787196481b5691f25/primp-1.1.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c0b4006a9a25c5f89a968f3bf67221fc19183890b8a1304873132d703697816", size = 3907535, upload-time = "2026-03-01T05:52:24.455Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/e6fe4abf75012d05009abf22e9e1eb89b4bca06ad9f79c10876cebdf7271/primp-1.1.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2851bedc1598ed72f6a2016e391012744259c523dc5d27f2f02e3ae5ef020d4", size = 4108136, upload-time = "2026-03-01T05:52:42.007Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/64cd76fee8b994f349c1a9c6541b4144dee64056dcaa8109bd352518b777/primp-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f7340e34023dda2660bd02cb92ac8ed441f13a1afdc00487581d8b8b473f890b", size = 4060289, upload-time = "2026-03-01T05:52:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/dd/7c/fbea74676def2ce1d21a53e86cdbb3ef9c7a12b2febfdd3961a8466449a7/primp-1.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:618a027bb45ac44e9b6c35d5758547ce5e73607de4fb54b52bb9d0dc896f11fa", size = 3749499, upload-time = "2026-03-01T05:51:59.988Z" }, + { url = "https://files.pythonhosted.org/packages/12/7a/36fc46a385141063e2ae4fd24dda308e75da8c6409c425a56ffceb6e4f71/primp-1.1.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:37e30ce1435142dd010f2ee1dd909f1e6e3a8cd3e32c8e22f3bb6703bf618209", size = 3858861, upload-time = "2026-03-01T05:52:10.621Z" }, + { url = "https://files.pythonhosted.org/packages/65/bb/d0319dbd2e20fb4f54d8b3f536b89431a9d1442f00fa11a874dfbe9d2de7/primp-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b5d335d28eae65543b20c75911d71c5f89882a4598efade47abe92389f6da7f", size = 4358677, upload-time = "2026-03-01T05:52:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/57/89/ab887a516dc83dbae12ea5b338f60c46a56966a972fed65f8de5bf05a9c2/primp-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:b938cc2d033ac56be90c617836a60fb468f33ab630d3eacab2b36651b7ce106e", size = 3258062, upload-time = "2026-03-01T05:52:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/df/ca/e870d65162f6c68da6d25afa3e01202ac500c8ad1b682dfd03e8c45e4d4a/primp-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:6378d55bbe8b722e7b39b6c0df1e46a1b767d2e4e8a7c1e60d9f8ec238bf48c4", size = 3599631, upload-time = "2026-03-01T05:52:03.595Z" }, + { url = "https://files.pythonhosted.org/packages/4e/cb/61667c710293d8007416130c9ad69f60a956393b52e82557c84ae8286aa7/primp-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:2431104658b86e7cf9bedbadabe6d2c4705c1c10b54f17ad0094cc927577adea", size = 3610624, upload-time = "2026-03-01T05:52:30.19Z" }, +] + +[[package]] +name = "proglog" +version = "0.1.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/af/c108866c452eda1132f3d6b3cb6be2ae8430c97e9309f38ca9dbd430af37/proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c", size = 8794, upload-time = "2025-05-09T14:36:18.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/1b/f7ea6cde25621cd9236541c66ff018f4268012a534ec31032bcb187dc5e7/proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c", size = 6337, upload-time = "2025-05-09T14:36:16.798Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] @@ -2083,36 +3830,35 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.2" +version = "5.29.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, - { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, - { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, ] [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -2120,22 +3866,6 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] [[package]] name = "pyasn1" @@ -2167,6 +3897,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2254,29 +4014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] -[[package]] -name = "pydocket" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ff/87e931e4abc7efb1e8c16adaa55327452931e8c5f460f2ba089447673226/pydocket-0.16.1.tar.gz", hash = "sha256:8663cb6dc801d8b8d703541fb665f4099c84f4d10d8f3fd441e208b080aa4826", size = 289028, upload-time = "2025-12-19T19:43:48.773Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/ab/0da7d0397112546309709f464bdf65de4e1697e3caba07556751fc4d8bcd/pydocket-0.16.1-py3-none-any.whl", hash = "sha256:bc6ccf7e91164761def854b4014101abf23c3cc2fb7d0fa2c4e07ea3bf6a1826", size = 63208, upload-time = "2025-12-19T19:43:47.309Z" }, -] - [[package]] name = "pydub" version = "0.25.1" @@ -2318,6 +4055,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, ] +[[package]] +name = "pypdf" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -2336,6 +4082,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -2400,12 +4159,22 @@ wheels = [ ] [[package]] -name = "python-json-logger" -version = "4.0.0" +name = "python-jose" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, ] [[package]] @@ -2450,6 +4219,15 @@ 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 = "pyunormalize" +version = "17.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ab/b912c484cfb96ba4834efe050bbf10c9e157bd8189eb859aefba8712b136/pyunormalize-17.0.0.tar.gz", hash = "sha256:0949a3e56817e287febcaf1b0cc4b5adf0bb107628d379335938040947eec792", size = 53121, upload-time = "2025-09-28T20:53:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/61512483dc509e3ae8a42fb143479d1e406ce1d91f8f08d538a3dde39c6d/pyunormalize-17.0.0-py3-none-any.whl", hash = "sha256:f0d93b076f938db2b26d319d04f2b58505d1cd7a80b5b72badbe7d1aa4d2a31c", size = 51358, upload-time = "2025-09-28T20:53:04.876Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2509,12 +4287,21 @@ wheels = [ ] [[package]] -name = "redis" -version = "7.1.0" +name = "qdrant-client" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/fb/c9c4cecf6e7fdff2dbaeee0de40e93fe495379eb5fe2775b184ea45315da/qdrant_client-1.17.0.tar.gz", hash = "sha256:47eb033edb9be33a4babb4d87b0d8d5eaf03d52112dca0218db7f2030bf41ba9", size = 344839, upload-time = "2026-02-19T16:03:17.069Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/dfadbc9d8c9872e8ac45fa96f5099bb2855f23426bfea1bbcdc85e64ef6e/qdrant_client-1.17.0-py3-none-any.whl", hash = "sha256:f5b452c68c42b3580d3d266446fb00d3c6e3aae89c916e16585b3c704e108438", size = 390381, upload-time = "2026-02-19T16:03:15.486Z" }, ] [[package]] @@ -2594,6 +4381,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, ] +[[package]] +name = "replicate" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/fd/caf6c59a6b8007366bd52ab5a320bf8d828f3860a60039309cfc0e375ec9/replicate-1.0.7.tar.gz", hash = "sha256:d88cb2c37ba39fb370c87fc3291601c67aae64bb918a20a85b5ce399c23ee84c", size = 62226, upload-time = "2025-05-27T11:29:08.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/5a/b3aa02a11a33de08e7771579154af3193decfb9d923b30b14c17b4e8bbce/replicate-1.0.7-py3-none-any.whl", hash = "sha256:667c50a9eb83be17de6278ff89483102b3b50f49a2c7fbcaa2e2b14df13816f9", size = 48626, upload-time = "2025-05-27T11:29:06.801Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2609,6 +4411,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + [[package]] name = "requests-oauthlib" version = "2.0.0" @@ -2622,6 +4436,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "resend" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/a3/20003e7d14604fef778bd30c69604df3560a657a95a5c29a9688610759b6/resend-2.23.0.tar.gz", hash = "sha256:df613827dcc40eb1c9de2e5ff600cd4081b89b206537dec8067af1a5016d23c7", size = 31416, upload-time = "2026-02-23T19:01:57.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/35/64df775b8cd95e89798fd7b1b7fcafa975b6b09f559c10c0650e65b33580/resend-2.23.0-py2.py3-none-any.whl", hash = "sha256:eca6d28a1ffd36c1fc489fa83cb6b511f384792c9f07465f7c92d96c8b4d5636", size = 52599, upload-time = "2026-02-23T19:01:55.962Z" }, +] + +[[package]] +name = "restrictedpython" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/1c/aec08bcb4ab14a1521579fbe21ceff2a634bb1f737f11cf7f9c8bb96e680/restrictedpython-8.1.tar.gz", hash = "sha256:4a69304aceacf6bee74bdf153c728221d4e3109b39acbfe00b3494927080d898", size = 838331, upload-time = "2025-10-19T14:11:32.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/c0/3848f4006f7e164ee20833ca984067e4b3fc99fe7f1dfa88b4927e681299/restrictedpython-8.1-py3-none-any.whl", hash = "sha256:4769449c6cdb10f2071649ba386902befff0eff2a8fd6217989fa7b16aeae926", size = 27651, upload-time = "2025-10-19T14:11:30.201Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -2648,6 +4496,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, ] +[[package]] +name = "rlp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eth-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/2d/439b0728a92964a04d9c88ea1ca9ebb128893fbbd5834faa31f987f2fd4c/rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9", size = 33429, upload-time = "2025-02-04T22:05:59.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973, upload-time = "2025-02-04T22:05:57.05Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2752,6 +4612,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +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/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]] name = "secretstorage" version = "3.5.0" @@ -2766,13 +4638,10 @@ wheels = [ ] [[package]] -name = "shellingham" -version = "1.5.4" +name = "sgmllib3k" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } [[package]] name = "six" @@ -2784,21 +4653,21 @@ wheels = [ ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "slack-sdk" +version = "3.40.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, ] [[package]] -name = "sortedcontainers" -version = "2.4.0" +name = "sniffio" +version = "1.3.1" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/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" }, + { 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]] @@ -2824,6 +4693,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/44/9645d4eccf04508c9677e4f75816c977a1ce306cf39e6f258f9d9deee8f9/speechrecognition-3.14.4-py3-none-any.whl", hash = "sha256:8b09d99a6ed31f994ed6b1749d6f921717e41179aa6387a79e8d514d30d98577", size = 32856066, upload-time = "2025-11-19T12:13:57.054Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "sse-starlette" version = "3.0.4" @@ -2897,6 +4810,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/ce/88565f0c9f7654bc90e19f1e76b3bffee7ff9c1741a2124ec2f2900fb080/tavily_python-0.7.17-py3-none-any.whl", hash = "sha256:a2725b9cba71e404e73d19ff277df916283c10100137c336e07f8e1bd7789fcf", size = 18214, upload-time = "2025-12-17T17:08:38.442Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -2937,6 +4859,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] +[[package]] +name = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, +] + +[[package]] +name = "tldextract" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "idna" }, + { name = "requests" }, + { name = "requests-file" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" }, +] + +[[package]] +name = "todoist-api-python" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "dataclass-wizard" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/6e/1ff0e0e098f2196f6eda3f20ebe0653a978abee5345f5ed1dfd1a9b5bbdc/todoist_api_python-3.2.1.tar.gz", hash = "sha256:22c60230cc616437846f9a6469dddd97548545ef916d2bea98b2ca2f522d92f9", size = 20689, upload-time = "2026-01-22T17:07:13.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/7c/6baa538db710f8d9cc4ed15f1f757ab9bc8564941d9c3291b9d7fcee7746/todoist_api_python-3.2.1-py3-none-any.whl", hash = "sha256:1091e9b557291380abd79245efe961b0f2261c7b4eb16e52beccfc6357320e95", size = 23541, upload-time = "2026-01-22T17:07:12.311Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -2950,18 +4919,36 @@ wheels = [ ] [[package]] -name = "typer" -version = "0.20.1" +name = "trafilatura" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "courlan" }, + { name = "htmldate" }, + { name = "justext" }, + { name = "lxml" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/25/e3ebeefdebfdfae8c4a4396f5a6ea51fc6fa0831d63ce338e5090a8003dc/trafilatura-2.0.0.tar.gz", hash = "sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247", size = 253404, upload-time = "2024-12-03T15:23:24.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/b6/097367f180b6383a3581ca1b86fcae284e52075fa941d1232df35293363c/trafilatura-2.0.0-py3-none-any.whl", hash = "sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d", size = 132557, upload-time = "2024-12-03T15:23:21.41Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c1/933d30fd7a123ed981e2a1eedafceab63cb379db0402e438a13bc51bbb15/typer-0.20.1.tar.gz", hash = "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b", size = 105968, upload-time = "2025-12-19T16:48:56.302Z" } + +[[package]] +name = "twilio" +version = "9.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pyjwt" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/a1/44cd8604eb69b1c5e7c0f07f0e4305b1884a3b75e23eb8d89350fe7bb982/twilio-9.10.2.tar.gz", hash = "sha256:f17d778870a7419a7278d5747b0e80a1c89e6f5ab14acf5456a004f8f2016bfa", size = 1618748, upload-time = "2026-02-18T04:40:44.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/52/1f2df7e7d1be3d65ddc2936d820d4a3d9777a54f4204f5ca46b8513eff77/typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", size = 47381, upload-time = "2025-12-19T16:48:53.679Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ac/e1937f70544075f896bfcd6b23fa7c15cad945e4598bcfa7017b7c120ad8/twilio-9.10.2-py2.py3-none-any.whl", hash = "sha256:8722bb59bacf31fab5725d6f5d3fac2224265c669d38f653f53179165533da43", size = 2256481, upload-time = "2026-02-18T04:40:42.226Z" }, ] [[package]] @@ -2973,6 +4960,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -3003,6 +5002,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "update-checker" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/0b/1bec4a6cc60d33ce93d11a7bcf1aeffc7ad0aa114986073411be31395c6f/update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", size = 6699, upload-time = "2020-08-04T07:08:50.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ba/8dd7fa5f0b1c6a8ac62f8f57f7e794160c1f86f31c6d0fb00f582372a3e4/update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd", size = 7008, upload-time = "2020-08-04T07:08:49.51Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0" @@ -3048,6 +5071,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "web3" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "eth-abi" }, + { name = "eth-account" }, + { name = "eth-hash", extra = ["pycryptodome"] }, + { name = "eth-typing" }, + { name = "eth-utils" }, + { name = "hexbytes" }, + { name = "pydantic" }, + { name = "pyunormalize" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/41/435cb36d36fc5142428292b876d0553d35af95e1582ecb7d8bcb64039d18/web3-7.14.1.tar.gz", hash = "sha256:856dc8517f362aefa75fdc298d975894055565dc866f21279f27fe060b7fb2c3", size = 2208998, upload-time = "2026-02-03T22:56:41.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/d1/862bbf48867685de1a563de20a9bad2b8c5c5678b3f08adc0e06797783f5/web3-7.14.1-py3-none-any.whl", hash = "sha256:bec367ba44261f874662aed9b5e138aa7bb907700a30a7580b2264534e88ce12", size = 1371268, upload-time = "2026-02-03T22:56:36.577Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -3068,6 +5194,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wikipedia" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/35/25e68fbc99e672127cc6fbb14b8ec1ba3dfef035bf1e4c90f78f24a80b7d/wikipedia-1.4.0.tar.gz", hash = "sha256:db0fad1829fdd441b1852306e9856398204dc0786d2996dd2e0c8bb8e26133b2", size = 27748, upload-time = "2014-11-15T15:59:49.808Z" } + [[package]] name = "win32-setctime" version = "1.2.0" @@ -3134,6 +5270,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, ] +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "yfinance" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1b/431d0ebd6a1e9deaffc8627cc4d26fd869841f31a1429cab7443eced0766/yfinance-1.2.0.tar.gz", hash = "sha256:80cec643eb983330ca63debab1b5492334fa1e6338d82cb17dd4e7b95079cfab", size = 140501, upload-time = "2026-02-16T19:52:34.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/60/462859de757ac56830824da7e8cf314b8b0321af5853df867c84cd6c2128/yfinance-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c27d1ebfc6275f476721cc6dba035a49d0cf9a806d6aa1785c9e10cf8a610d8", size = 130247, upload-time = "2026-02-16T19:52:33.109Z" }, +] + [[package]] name = "youtube-transcript-api" version = "1.0.3" @@ -3147,6 +5392,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/44/40c03bb0f8bddfb9d2beff2ed31641f52d96c287ba881d20e0c074784ac2/youtube_transcript_api-1.0.3-py3-none-any.whl", hash = "sha256:d1874e57de65cf14c9d7d09b2b37c814d6287fa0e770d4922c4cd32a5b3f6c47", size = 2169911, upload-time = "2025-03-25T18:14:19.416Z" }, ] +[[package]] +name = "zep-cloud" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/b5/a601962090d16360ff408e847c7b6acc9002da42f319332f806a085165ea/zep_cloud-3.17.0.tar.gz", hash = "sha256:5299c588c7ccf1c2af59702ebe99775e4881cc1c1ef17163fafcc8935803f5a2", size = 69976, upload-time = "2026-03-02T15:35:03.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/99/c16deefb5abf5728bdc960bbfc6f34b6d5692aeae60c79e6eac98ba9f9f1/zep_cloud-3.17.0-py3-none-any.whl", hash = "sha256:320139ab7fddd4acc2797be2125b471bbe9fece177131ae963ed40d82d2bdd33", size = 121852, upload-time = "2026-03-02T15:35:02.152Z" }, +] + [[package]] name = "zipp" version = "3.23.0"